diff --git a/rag/__init__.py b/rag/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rag/admin.py b/rag/admin.py new file mode 100644 index 0000000..a380f41 --- /dev/null +++ b/rag/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin + +from .models import LessonChunk, LessonChunkImage + + +class LessonChunkImageInline(admin.TabularInline): + model = LessonChunkImage + extra = 0 + + +@admin.register(LessonChunk) +class LessonChunkAdmin(admin.ModelAdmin): + list_display = ('id', 'subject', 'grade_id', 'lesson_id', 'lesson_number', 'order', 'title', 'collection', 'created_at') + list_filter = ('subject', 'grade_id', 'collection') + search_fields = ('title', 'summary', 'original_text') + readonly_fields = ('qdrant_point_id', 'created_at') + inlines = [LessonChunkImageInline] + + +@admin.register(LessonChunkImage) +class LessonChunkImageAdmin(admin.ModelAdmin): + list_display = ('id', 'chunk', 'description', 'created_at') diff --git a/rag/apps.py b/rag/apps.py new file mode 100644 index 0000000..39b615c --- /dev/null +++ b/rag/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RagConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'rag' diff --git a/rag/management/__init__.py b/rag/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rag/management/commands/__init__.py b/rag/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rag/management/commands/rag_catalog.py b/rag/management/commands/rag_catalog.py new file mode 100644 index 0000000..3d8837f --- /dev/null +++ b/rag/management/commands/rag_catalog.py @@ -0,0 +1,77 @@ +""" +فهرستِ زنده‌ی collectionهای Qdrant + تعدادِ نقطه و مقادیرِ فیلترِ درس/فصل. + +فقط خواندنی؛ هیچ‌چیزی روی Qdrant نمی‌نویسد. برای این‌که همیشه بدونیم چه +کتاب/درسی روی سرورِ تیمِ دیگه در دسترس است و rag/services/known_collections.py +با واقعیت هم‌خوان است. + + python manage.py rag_catalog + python manage.py rag_catalog --values # مقادیرِ lesson_number/chapter را هم چاپ کن +""" +import json + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = 'فهرستِ زنده‌ی collectionهای Qdrant (فقط خواندنی).' + + def add_arguments(self, parser): + parser.add_argument('--values', action='store_true', + help='مقادیرِ متمایزِ lesson_number/chapter هر collection را هم چاپ کن.') + parser.add_argument('--json', action='store_true', help='خروجی JSON.') + + def handle(self, *args, **opts): + from rag.services.clients import RagNotConfigured, get_qdrant_client + + try: + client = get_qdrant_client() + names = sorted(c.name for c in client.get_collections().collections) + except RagNotConfigured as exc: + self.stderr.write(self.style.ERROR(f'RAG پیکربندی نشده: {exc}')) + return + except Exception as exc: # noqa: BLE001 + self.stderr.write(self.style.ERROR(f'اتصال به Qdrant شکست خورد: {exc}')) + return + + catalog = [] + for name in names: + info = client.get_collection(name) + row = { + 'collection': name, + 'points': info.points_count, + 'vectorSize': info.config.params.vectors.size, + } + if opts['values']: + lessons, chapters = set(), set() + offset = None + while True: + pts, offset = client.scroll(collection_name=name, limit=256, + with_payload=True, with_vectors=False, offset=offset) + for p in pts: + pl = p.payload or {} + if 'lesson_number' in pl: + lessons.add(pl['lesson_number']) + if 'chapter' in pl: + chapters.add(pl['chapter']) + if offset is None: + break + if lessons: + row['lessonNumbers'] = sorted(lessons, key=lambda x: (isinstance(x, str), x)) + if chapters: + row['chapters'] = sorted(chapters) + catalog.append(row) + + if opts['json']: + self.stdout.write(json.dumps(catalog, ensure_ascii=False, indent=2)) + return + + total_points = sum(r['points'] for r in catalog) + self.stdout.write(self.style.SUCCESS(f'{len(catalog)} collection، مجموعاً {total_points} نقطه:\n')) + for r in catalog: + self.stdout.write(f" • {r['collection']} — {r['points']} نقطه (بُعد {r['vectorSize']})") + if 'lessonNumbers' in r: + self.stdout.write(f" lesson_number: {r['lessonNumbers']}") + if 'chapters' in r: + for ch in r['chapters']: + self.stdout.write(f" chapter: {ch!r}") diff --git a/rag/migrations/0001_initial.py b/rag/migrations/0001_initial.py new file mode 100644 index 0000000..2076a32 --- /dev/null +++ b/rag/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 5.2.17 on 2026-08-28 18:56 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='LessonChunk', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('lesson_id', models.IntegerField(db_index=True)), + ('grade_id', models.IntegerField(blank=True, null=True)), + ('subject', models.CharField(max_length=100)), + ('lesson_number', models.IntegerField()), + ('chapter', models.IntegerField(blank=True, null=True)), + ('order', models.PositiveIntegerField()), + ('title', models.CharField(max_length=255)), + ('original_text', models.TextField()), + ('summary', models.TextField()), + ('embedding_vector', models.JSONField(blank=True, null=True)), + ('collection', models.CharField(db_index=True, max_length=200)), + ('qdrant_point_id', models.UUIDField(default=uuid.uuid4, editable=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['lesson_id', 'order'], + 'indexes': [models.Index(fields=['subject', 'lesson_number'], name='rag_lessonc_subject_73ff32_idx')], + 'constraints': [models.UniqueConstraint(fields=('collection', 'qdrant_point_id'), name='uniq_lesson_chunk_qdrant_point')], + }, + ), + migrations.CreateModel( + name='LessonChunkImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('img_url', models.TextField()), + ('description', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('chunk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='rag.lessonchunk')), + ], + ), + ] diff --git a/rag/migrations/__init__.py b/rag/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rag/models.py b/rag/models.py new file mode 100644 index 0000000..67297f9 --- /dev/null +++ b/rag/models.py @@ -0,0 +1,63 @@ +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 قابلِ بازسازی باشه. + """ + + lesson_id = models.IntegerField(db_index=True) # ارجاع سبک به content.Lesson + 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) + + order = models.PositiveIntegerField() + title = models.CharField(max_length=255) + original_text = models.TextField() + summary = models.TextField() + embedding_vector = models.JSONField(null=True, 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']), + ] + + 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}" diff --git a/rag/serializers.py b/rag/serializers.py new file mode 100644 index 0000000..d59cfcb --- /dev/null +++ b/rag/serializers.py @@ -0,0 +1,53 @@ +from rest_framework import serializers + + +class IngestTextRequestSerializer(serializers.Serializer): + lessonId = serializers.IntegerField() + gradeId = serializers.IntegerField(required=False, allow_null=True) + subject = serializers.CharField() + lessonNumber = serializers.IntegerField() + chapter = serializers.IntegerField(required=False, allow_null=True) + title = serializers.CharField(required=False, allow_blank=True, default='') + text = serializers.CharField() + + +class IngestPdfRequestSerializer(serializers.Serializer): + lessonId = serializers.IntegerField() + gradeId = serializers.IntegerField(required=False, allow_null=True) + subject = serializers.CharField() + lessonNumber = serializers.IntegerField() + chapter = serializers.IntegerField(required=False, allow_null=True) + title = serializers.CharField(required=False, allow_blank=True, default='') + chapterName = serializers.CharField(required=False, allow_blank=True, default='') + # یکی از این دو: فایل مستقیم (multipart) یا base64 + pdf = serializers.FileField(required=False) + pdfBase64 = serializers.CharField(required=False, allow_blank=True) + + def validate(self, attrs): + if not attrs.get('pdf') and not attrs.get('pdfBase64'): + raise serializers.ValidationError('یکی از pdf یا pdfBase64 لازم است.') + return attrs + + +class IngestResponseSerializer(serializers.Serializer): + status = serializers.CharField() + collection = serializers.CharField() + chunksCreated = serializers.IntegerField() + chunkIds = serializers.ListField(child=serializers.IntegerField(), required=False) + + +class ChunkSerializer(serializers.Serializer): + id = serializers.IntegerField() + lessonId = serializers.IntegerField(source='lesson_id') + subject = serializers.CharField() + lessonNumber = serializers.IntegerField(source='lesson_number') + chapter = serializers.IntegerField(allow_null=True) + order = serializers.IntegerField() + title = serializers.CharField() + summary = serializers.CharField() + collection = serializers.CharField() + + +class ChunkListResponseSerializer(serializers.Serializer): + count = serializers.IntegerField() + results = ChunkSerializer(many=True) diff --git a/rag/services/__init__.py b/rag/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rag/services/answer_cache.py b/rag/services/answer_cache.py new file mode 100644 index 0000000..935124c --- /dev/null +++ b/rag/services/answer_cache.py @@ -0,0 +1,42 @@ +""" +کشِ پاسخِ RAG برای سوالِ عیناً تکراری. + +کلید = هشِ (collection + مقدارِ فیلترِ درس/فصل + متنِ نرمال‌شده‌ی سوال). +فقط مرحله‌ی گران (embedding + جستجوی Qdrant + تولیدِ GPT) رو رد می‌کنه؛ +کوتای روزانه، idempotency و ثبتِ Message همچنان در view اجرا می‌شن. +""" +import hashlib + +from django.conf import settings +from django.core.cache import cache + +_PREFIX = 'rag:ans:v1:' + + +def ttl() -> int: + return int(getattr(settings, 'RAG_ANSWER_CACHE_TTL', 0)) + + +def enabled() -> bool: + return ttl() > 0 + + +def _norm(text: str) -> str: + return ' '.join((text or '').split()).strip().lower() + + +def make_key(collection, filter_value, question: str) -> str: + raw = f'{collection}|{filter_value}|{_norm(question)}' + return _PREFIX + hashlib.sha1(raw.encode('utf-8')).hexdigest() + + +def get(key: str) -> dict | None: + if not enabled(): + return None + return cache.get(key) + + +def set(key: str, payload: dict) -> None: # noqa: A003 + if not enabled(): + return + cache.set(key, payload, ttl()) diff --git a/rag/services/chunking.py b/rag/services/chunking.py new file mode 100644 index 0000000..9e535fd --- /dev/null +++ b/rag/services/chunking.py @@ -0,0 +1,92 @@ +""" +تقسیمِ متنِ درس به تکه‌های مستقل + خلاصه‌سازیِ آموزشیِ هر تکه. + +برداشتِ پاک‌سازی‌شده از subjects/services/chunker.py مخزنِ Rag_backend: +- به‌جای HttpError نینجا، از استثناهای معمولی استفاده می‌کنیم (لایه‌ی view + در DRF تبدیلش می‌کنه). +- کلاینت lazy است. +""" +import json + +from django.conf import settings + +from . import clients + + +class ChunkingError(RuntimeError): + pass + + +def split_text_into_chunks(text: str, chunk_size: int = 400, overlap: int = 100) -> list[str]: + if not text: + return [] + chunks: list[str] = [] + start = 0 + text_length = len(text) + step = max(chunk_size - overlap, 1) + while start < text_length: + end = min(start + chunk_size, text_length) + chunks.append(text[start:end]) + start += step + return chunks + + +def _strip_code_fence(raw: str) -> str: + raw = raw.strip() + if raw.startswith('```json'): + raw = raw[len('```json'):] + elif raw.startswith('```'): + raw = raw[len('```'):] + if raw.endswith('```'): + raw = raw[:-3] + return raw.strip() + + +def summarize_chunk(chunk_text: str) -> str: + """خلاصه‌ی مستقل و آموزشیِ یک تکه به فارسی.""" + prompt = f""" + You are an expert Persian educational summarizer. + Summarize the following text clearly and independently. + + - The summary must be self-contained. + - Capture all key educational points. + - Write in natural Persian (Farsi). + + Text: + {chunk_text} + + Return JSON only: + {{"summary": "خلاصه کامل این بخش در فارسی"}} + """ + client = clients.get_openai_client() + response = client.chat.completions.create( + model=settings.OPENAI_CHAT_MODEL, + messages=[ + {'role': 'system', 'content': 'You are a helpful assistant that always outputs valid JSON.'}, + {'role': 'user', 'content': prompt}, + ], + max_tokens=400, + temperature=0.4, + top_p=0.9, + ) + raw = _strip_code_fence(response.choices[0].message.content or '') + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise ChunkingError(f'خروجیِ خلاصه‌سازی JSON معتبر نبود: {raw[:300]}') from exc + summary = data.get('summary') + if not summary: + raise ChunkingError(f'کلیدِ summary در خروجی نبود: {raw[:300]}') + return summary + + +def chunk_and_summarize(text: str, chunk_size: int = 400, overlap: int = 100) -> list[dict]: + """[{order, original_text, summary}, ...]""" + result = [] + for index, original in enumerate(split_text_into_chunks(text, chunk_size, overlap), start=1): + result.append({ + 'order': index, + 'original_text': original, + 'summary': summarize_chunk(original), + }) + return result diff --git a/rag/services/clients.py b/rag/services/clients.py new file mode 100644 index 0000000..3647c98 --- /dev/null +++ b/rag/services/clients.py @@ -0,0 +1,55 @@ +""" +کلاینت‌های OpenAI و Qdrant به‌صورتِ lazy (نه در سطحِ ماژول). + +مخزنِ Rag_backend این‌ها رو موقعِ import می‌ساخت؛ نتیجه این بود که بدونِ +کلید/شبکه، خودِ import هم crash می‌کرد. اینجا کلاینت فقط وقتی واقعاً لازم +بشه ساخته می‌شه و بینِ فراخوانی‌ها cache می‌مونه. +""" +from functools import lru_cache + +from django.conf import settings + + +class RagNotConfigured(RuntimeError): + """کلید/کتابخانه‌ی لازم برای مسیرِ RAG موجود نیست.""" + + +@lru_cache(maxsize=1) +def get_openai_client(): + if not settings.OPENAI_API_KEY: + raise RagNotConfigured('OPENAI_API_KEY تنظیم نشده است.') + try: + from openai import OpenAI + except ModuleNotFoundError as exc: + raise RagNotConfigured('پکیجِ openai نصب نیست (pip install -r requirements.txt).') from exc + + return OpenAI( + base_url=settings.OPENAI_BASE_URL, + api_key=settings.OPENAI_API_KEY, + timeout=settings.OPENAI_TIMEOUT_SECONDS, + ) + + +@lru_cache(maxsize=1) +def get_qdrant_client(): + if not settings.QDRANT_API_KEY: + raise RagNotConfigured('QDRANT_API_KEY تنظیم نشده است.') + try: + from qdrant_client import QdrantClient + except ModuleNotFoundError as exc: + raise RagNotConfigured('پکیجِ qdrant-client نصب نیست (pip install -r requirements.txt).') from exc + + # check_compatibility=False: کلاینتِ نصب‌شده (1.19) با سرورِ تیمِ دیگه + # (1.17) اختلافِ minor داره و کلاینت warning می‌ده؛ API‌هایی که استفاده + # می‌کنیم (get_collections/upsert/query_points/scroll) پایدارن. + return QdrantClient( + url=settings.QDRANT_URL, + api_key=settings.QDRANT_API_KEY, + check_compatibility=False, + ) + + +def reset_clients_cache(): + """برای تست‌ها / بعد از تغییرِ settings در زمانِ اجرا.""" + get_openai_client.cache_clear() + get_qdrant_client.cache_clear() diff --git a/rag/services/config.py b/rag/services/config.py new file mode 100644 index 0000000..55f6f88 --- /dev/null +++ b/rag/services/config.py @@ -0,0 +1,44 @@ +""" +هم‌خوانیِ نامِ collection و کلیدِ فیلترِ Qdrant با دادهٔ موجودِ تیمِ دیگر. + +در Rag_backend نامِ collection این‌طور ساخته می‌شه: + f"{subject.name} {subject.grade.title}".lower() +و فیلترِ درس با کلیدِ payloadِ "lesson_number" (برای درس‌های متنی) یا +"chapter" (برای ریاضی) انجام می‌شه. اینجا هر دو از settings قابلِ تنظیم‌ان +چون اسمِ دقیقِ گرید حساسه (کاربر قبلاً با اسمِ نادرست به مشکل خورده بود). +""" +from django.conf import settings + + +def rag_is_configured() -> bool: + return bool( + settings.OPENAI_API_KEY + and settings.QDRANT_API_KEY + and not settings.RAG_FORCE_MOCK + ) + + +def resolve_grade_title(grade_id, fallback: str = '') -> str: + """gradeId → عنوانِ گرید همان‌طور که در نامِ collectionِ تیمِ دیگه اومده.""" + overrides = getattr(settings, 'RAG_GRADE_TITLE_OVERRIDES', {}) or {} + if grade_id in overrides: + return str(overrides[grade_id]) + if grade_id is not None: + try: + from content.models import Grade + + grade = Grade.objects.filter(pk=grade_id).only('title').first() + if grade is not None: + return grade.title + except Exception: + pass + return fallback + + +def build_collection_name(subject: str, grade_title: str) -> str: + template = getattr(settings, 'RAG_COLLECTION_NAME_TEMPLATE', '{subject} {grade}') + return template.format(subject=(subject or '').strip(), grade=(grade_title or '').strip()).lower() + + +def retrieval_top_k() -> int: + return int(getattr(settings, 'RAG_RETRIEVAL_TOP_K', 4)) diff --git a/rag/services/embedding.py b/rag/services/embedding.py new file mode 100644 index 0000000..fe9f489 --- /dev/null +++ b/rag/services/embedding.py @@ -0,0 +1,18 @@ +"""تولیدِ embedding با همون مدلی که collectionهای Qdrant باهاش ساخته شدن.""" +from django.conf import settings + +from . import clients + + +def embed_text(text: str) -> list[float]: + """ + برداریِ یک متن. مدل باید دقیقاً OPENAI_EMBEDDING_MODEL باشه (پیش‌فرض + text-embedding-3-small، بُعد ۱۵۳۶) وگرنه با نقاطِ موجودِ collection + هم‌خوان نیست. + """ + client = clients.get_openai_client() + response = client.embeddings.create( + model=settings.OPENAI_EMBEDDING_MODEL, + input=text[:8000], + ) + return response.data[0].embedding diff --git a/rag/services/generation.py b/rag/services/generation.py new file mode 100644 index 0000000..144affd --- /dev/null +++ b/rag/services/generation.py @@ -0,0 +1,98 @@ +""" +مرحله‌ی generation: پرسش + context بازیابی‌شده → پاسخِ فارسی + چند پیشنهادِ +ادامه. + +اگه context خالی باشه، مدل به‌عنوانِ معلمِ عمومی جواب می‌ده (بدونِ ادعای +استناد به کتاب) — این حالت وقتی پیش می‌آد که subject/lessonNumber ارسال +نشده یا اون درس هنوز در Qdrant نیست. +""" +import logging +from collections.abc import Iterator + +from django.conf import settings + +from . import clients + +logger = logging.getLogger(__name__) + +_DEFAULT_FOLLOW_UPS = ['یک مثال دیگه بزن', 'بیشتر توضیح بده'] + +_SYSTEM = 'تو یک معلمِ فارسی‌زبانِ دبستانی هستی و پاسخ‌ها را روشن، ساده و کوتاه می‌دهی.' + + +def _prompt_with_context(question: str, contexts: list[str]) -> str: + joined = '\n\n---\n\n'.join(contexts) + return f""" + تو معلمِ دبستانی هستی. با تکیه بر «متنِ درس» زیر و با زبانِ خودت، به سوالِ + دانش‌آموز پاسخِ روشن و ساده بده. لازم نیست پاسخ عیناً در متن باشد؛ می‌توانی + از اطلاعاتِ متن نتیجه‌گیری و توضیح بدهی. + فقط اگر متنِ درس آشکارا درباره‌ی موضوعِ دیگری‌ست و هیچ ربطی به سوال ندارد، + بنویس: «این موضوع در این درس پوشش داده نشده است.» + + متنِ درس: + {joined} + + سوالِ دانش‌آموز: {question} + """ + + +def _prompt_without_context(question: str) -> str: + return f""" + به این سوالِ یک دانش‌آموزِ دبستانی به فارسی، ساده و کوتاه پاسخ بده. + + سوال: {question} + """ + + +def generate_answer(question: str, context_chunks: list[dict] | None) -> tuple[str, list[str]]: + """ + context_chunks: خروجیِ retrieve_context ([{title, summary, score}, ...]) یا None/[]. + خروجی: (answer, follow_ups) + """ + contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')] + prompt = _prompt_with_context(question, contexts) if contexts else _prompt_without_context(question) + + client = clients.get_openai_client() + completion = client.chat.completions.create( + model=settings.OPENAI_CHAT_MODEL, + messages=[ + {'role': 'system', 'content': _SYSTEM}, + {'role': 'user', 'content': prompt}, + ], + temperature=0.3, + ) + answer = (completion.choices[0].message.content or '').strip() + if not answer: + answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.' + return answer, list(_DEFAULT_FOLLOW_UPS) + + +def stream_answer(question: str, context_chunks: list[dict] | None) -> Iterator[str]: + """ + همون generate_answer ولی توکن‌به‌توکن (برای SSE). فقط قطعه‌های متنِ پاسخ + را yield می‌کند؛ follow_upها ثابت‌اند (_DEFAULT_FOLLOW_UPS) و caller خودش + اضافه‌شان می‌کند. + """ + contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')] + prompt = _prompt_with_context(question, contexts) if contexts else _prompt_without_context(question) + + client = clients.get_openai_client() + stream = client.chat.completions.create( + model=settings.OPENAI_CHAT_MODEL, + messages=[ + {'role': 'system', 'content': _SYSTEM}, + {'role': 'user', 'content': prompt}, + ], + temperature=0.3, + stream=True, + ) + for event in stream: + if not event.choices: + continue + delta = event.choices[0].delta.content or '' + if delta: + yield delta + + +def default_follow_ups() -> list[str]: + return list(_DEFAULT_FOLLOW_UPS) diff --git a/rag/services/ingest.py b/rag/services/ingest.py new file mode 100644 index 0000000..8a2fec8 --- /dev/null +++ b/rag/services/ingest.py @@ -0,0 +1,138 @@ +""" +هماهنگ‌کننده‌ی ingestion: متن/PDF درس → chunk/خلاصه/embedding → ذخیره در +DB (LessonChunk، منبعِ حقیقت) + upsert به Qdrant (index). + +نامِ collection و کلیدهای payload عمداً همون قراردادِ Rag_backend را دنبال +می‌کنن تا با دادهٔ موجودِ تیمِ دیگه سازگار بمونه. +""" +import logging +import os +import tempfile + +from django.db import transaction + +from ..models import LessonChunk +from .chunking import chunk_and_summarize +from .config import build_collection_name, resolve_grade_title +from .embedding import embed_text +from . import pdf as pdf_service +from .qdrant_store import ensure_collection, upsert_points + +logger = logging.getLogger(__name__) + + +def _payload(chunk: LessonChunk) -> dict: + return { + 'lesson_id': chunk.lesson_id, + 'lesson_number': chunk.lesson_number, + 'chapter_number': chunk.chapter, + 'chunk_order': chunk.order, + 'lesson_title': chunk.title, + 'summary': chunk.summary, + } + + +def _persist_and_upsert(rows: list[dict], *, lesson_id, grade_id, subject, + lesson_number, chapter, title, collection) -> list[LessonChunk]: + ensure_collection(collection) + created: list[LessonChunk] = [] + points: list[dict] = [] + + with transaction.atomic(): + for row in rows: + vector = embed_text(row['summary']) + chunk = LessonChunk.objects.create( + lesson_id=lesson_id, + grade_id=grade_id, + subject=subject, + lesson_number=lesson_number, + chapter=chapter, + order=row['order'], + title=title, + original_text=row['original_text'], + summary=row['summary'], + embedding_vector=vector, + collection=collection, + ) + created.append(chunk) + points.append({'id': chunk.qdrant_point_id, 'vector': vector, 'payload': _payload(chunk)}) + + upsert_points(collection, points) + return created + + +def ingest_text(*, lesson_id: int, grade_id, subject: str, lesson_number: int, + text: str, chapter: int | None = None, title: str = '') -> dict: + grade_title = resolve_grade_title(grade_id) + collection = build_collection_name(subject, grade_title) + title = title or f'lesson_{lesson_number}' + + rows = chunk_and_summarize(text) + if not rows: + return {'status': 'empty', 'chunks_created': 0, 'collection': collection} + + created = _persist_and_upsert( + rows, lesson_id=lesson_id, grade_id=grade_id, subject=subject, + lesson_number=lesson_number, chapter=chapter, title=title, collection=collection, + ) + return { + 'status': 'success', + 'collection': collection, + 'chunks_created': len(created), + 'chunk_ids': [c.id for c in created], + } + + +def ingest_pdf(*, lesson_id: int, grade_id, subject: str, lesson_number: int, + pdf_bytes: bytes, chapter: int | None = None, title: str = '', + chapter_name: str = '') -> dict: + """ + هر صفحه‌ی PDF → عکس → استخراجِ انواعِ مسئله با vision → هر problemType یک + chunk. برای درس‌های متنی به‌جای این از ingest_text استفاده کن. + """ + grade_title = resolve_grade_title(grade_id) + collection = build_collection_name(subject, grade_title) + title = title or f'lesson_{lesson_number}' + chapter_name = chapter_name or title + + fd, tmp_path = tempfile.mkstemp(suffix='.pdf') + try: + with os.fdopen(fd, 'wb') as fh: + fh.write(pdf_bytes) + + start_page, detected_grade = pdf_service.detect_grade_and_start_page(tmp_path) + images = pdf_service.extract_page_images(tmp_path, first_page=start_page) + + rows: list[dict] = [] + order = 0 + for image in images: + try: + parsed = pdf_service.extract_problem_types(image, detected_grade or grade_title, chapter_name) + except Exception: + logger.exception('rag.ingest_pdf: تحلیلِ یک صفحه شکست خورد؛ رد شد') + continue + for problem_type in parsed.get('problemTypes', []) or []: + text = pdf_service.problem_type_to_text(problem_type) + if not text: + continue + order += 1 + rows.append({'order': order, 'original_text': text, 'summary': text}) + finally: + try: + os.remove(tmp_path) + except OSError: + pass + + if not rows: + return {'status': 'empty', 'chunks_created': 0, 'collection': collection} + + created = _persist_and_upsert( + rows, lesson_id=lesson_id, grade_id=grade_id, subject=subject, + lesson_number=lesson_number, chapter=chapter, title=title, collection=collection, + ) + return { + 'status': 'success', + 'collection': collection, + 'chunks_created': len(created), + 'chunk_ids': [c.id for c in created], + } diff --git a/rag/services/known_collections.py b/rag/services/known_collections.py new file mode 100644 index 0000000..561bde9 --- /dev/null +++ b/rag/services/known_collections.py @@ -0,0 +1,102 @@ +""" +کاتالوگِ دقیقِ collectionهای موجود روی Qdrant تیمِ دیگر +(qdrant.tadrisino2.ir) — استخراج‌شده و بررسی‌شده در ۱۴۰۵/۰۶/۰۶ (2026-08-28). + +هدف: کاربر لازم نباشه رشته‌های دقیقِ گرید/فصل رو حفظ کنه. با داشتنِ subject و +یک شماره‌ی ساده (شماره‌ی درس یا شماره‌ی فصل)، retrieval خودش: + - کلیدِ فیلترِ درست رو انتخاب می‌کنه (ریاضی → chapter ، بقیه → lesson_number) + - برای ریاضی، شماره‌ی فصل رو به رشته‌ی دقیقِ فصل تبدیل می‌کنه. + +اگه تیمِ دیگه collection جدید اضافه/تغییر داد، فقط همین فایل به‌روز بشه. +""" + +# --- درس‌های متنی: payload کلیدِ `lesson_number` (رشته) + `summary` دارد --- +# نامِ collection = f"{subject} {grade}".lower() +TEXT_SUBJECT_LESSON_FILTER_KEY = 'lesson_number' + +# --- ریاضی: payload کلیدِ `chapter` (رشته) + `generalSolution` دارد، `summary` ندارد --- +MATH_LESSON_FILTER_KEY = 'chapter' + +# subjectهایی که ساختارِ «ریاضی» دارند (فیلتر با chapter، متن از generalSolution) +MATH_SUBJECTS = {'ریاضی'} + +# فصول ریاضی به تفکیکِ (subject, grade) — کلیدِ داخلی هم شماره‌ی فصل و هم +# رشته‌ی کامل رو می‌پذیره. +MATH_CHAPTERS = { + ('ریاضی', 'سوم'): { + 1: 'فصل ۱: الگوها', + 2: 'فصل ۲: عددهای چهار رقمی', + 3: 'فصل ۳: عددهای کسری', + 4: 'فصل ۴: ضرب و تقسیم', + 5: 'فصل ۵: محیط و مساحت', + 6: 'فصل ۶: جمع و تفریق', + 7: 'فصل ۷: آمار و احتمال', + 8: 'فصل ۸: ضرب عددها', + }, + ('ریاضی', 'ششم'): { + 1: 'فصل ۱: عدد و الگوهای عددی', + 2: 'فصل ۲: کسر', + 3: 'فصل ۳: اعداد اعشاری', + 4: 'فصل ۴: تقارن و مختصات', + 5: 'فصل ۵: اندازه گیری', + 6: 'فصل ۶: تناسب و درصد', + 7: 'فصل ۷: تقریب', + }, + ('ریاضی', 'هفتم'): { + 1: 'فصل ۱: راهبردهای حل مسئله', + 2: 'فصل ۲: عددهای صحیح', + 3: 'فصل ۳: جبر و معادله', + 4: 'فصل ۴: هندسه و استدلال', + 5: 'فصل ۵: شمارنده‌ها و اعداد اول', + 6: 'فصل ۶: سطح و حجم', + 7: 'فصل ۷: توان و جذر', + 8: 'فصل ۸: بردار و مختصات', + 9: 'فصل ۹: آمار و احتمال', + }, +} + +# درس‌های علوم/اجتماعی — فقط برای مرجع/اعتبارسنجی (lesson_number → عنوان). +TEXT_LESSONS = { + ('علوم تجربی', 'سوم'): { + 1: 'زنگ علوم', 2: 'خوراکی‌ها', 3: 'اندازه‌گیری مواد', 4: 'مواد اطراف ما', + 5: 'آب ماده‌ی باارزش', 6: 'زندگی ما و آب', 7: 'نور و مشاهده‌ی اجسام', + 8: 'جستجو کنیم و بسازیم', 9: 'نیرو همه‌جا ۱', 10: 'نیرو همه‌جا ۲', + 11: 'بکارید و ببینید', 12: 'هرکدام جای خود ۱', 13: 'هرکدام جای خود ۲', + 14: 'از گذشته تا آینده(نگهداری مواد غذایی)', + }, + ('مطالعات اجتماعی', 'ششم'): {1: 'lesson_1', 2: 'lesson_2', 3: 'lesson_3', 9: 'lesson_9'}, + ('مطالعات اجتماعی', 'چهارم'): {1: 'lesson_1', 2: 'lesson_2', 3: 'lesson_3'}, +} + +# فهرستِ کاملِ collectionهای موجود (نامِ نهایی، lowercase). +KNOWN_COLLECTIONS = { + 'ریاضی سوم', 'ریاضی ششم', 'ریاضی هفتم', + 'علوم تجربی سوم', 'مطالعات اجتماعی ششم', 'مطالعات اجتماعی چهارم', +} + + +def is_math_subject(subject: str) -> bool: + return (subject or '').strip() in MATH_SUBJECTS + + +def filter_key_for_subject(subject: str) -> str: + return MATH_LESSON_FILTER_KEY if is_math_subject(subject) else TEXT_SUBJECT_LESSON_FILTER_KEY + + +def resolve_math_chapter(subject: str, grade: str, lesson_or_chapter) -> str | None: + """ + برای ریاضی: ورودی می‌تونه شماره‌ی فصل (2 / '2' / '۲') یا خودِ رشته‌ی + کامل باشه. خروجی رشته‌ی دقیقِ `chapter` در Qdrant یا None. + """ + if lesson_or_chapter is None: + return None + table = MATH_CHAPTERS.get((( subject or '').strip(), (grade or '').strip())) + text = str(lesson_or_chapter).strip() + if text.startswith('فصل'): + return text + # نرمال‌سازی ارقامِ فارسی → لاتین + trans = str.maketrans('۰۱۲۳۴۵۶۷۸۹', '0123456789') + digits = text.translate(trans) + if table and digits.isdigit() and int(digits) in table: + return table[int(digits)] + return text or None diff --git a/rag/services/pdf.py b/rag/services/pdf.py new file mode 100644 index 0000000..528a383 --- /dev/null +++ b/rag/services/pdf.py @@ -0,0 +1,126 @@ +""" +استخراج از PDF کتابِ درسی + تحلیلِ عکسِ صفحه با مدلِ vision. + +برداشتِ پاک‌سازی‌شده از mathapp/services/read_pdf.py و info_question.py مخزنِ +Rag_backend. به‌جای نوشتنِ نتیجه در یک فایلِ JSON روی دیسک، دیکشنری +برمی‌گردونه تا لایه‌ی ingest تصمیم بگیره. + +pymupdf (fitz) و pdf2image اختیاری‌ان؛ نبودشون فقط این ماژول رو از کار +می‌ندازه (RagNotConfigured)، نه بقیه‌ی پروژه رو. +""" +import base64 +import json +from io import BytesIO + +from django.conf import settings + +from . import clients +from .clients import RagNotConfigured + + +def extract_page_images(pdf_path: str, first_page: int = 1, dpi: int = 200): + """لیستی از تصاویرِ PIL برای صفحاتِ PDF از first_page به بعد.""" + try: + from pdf2image import convert_from_path + except ModuleNotFoundError as exc: + raise RagNotConfigured('pdf2image نصب نیست یا poppler موجود نیست.') from exc + return convert_from_path(pdf_path, dpi=dpi, first_page=first_page) + + +def detect_grade_and_start_page(pdf_path: str) -> tuple[int, str | None]: + """ + از متنِ صفحه‌ی اولِ PDF، پایه‌ی تحصیلی و صفحه‌ای که محتوای واقعی از اونجا + شروع می‌شه رو حدس می‌زنه (مثلِ نسخه‌ی Rag_backend). + """ + try: + import fitz # PyMuPDF + except ModuleNotFoundError as exc: + raise RagNotConfigured('pymupdf (fitz) نصب نیست.') from exc + + start_pages = {'پنجم دبستان': 11, 'ششم دبستان': 9} + grades = ['هفتم', 'اول دبستان', 'دوم دبستان', 'سوم دبستان', + 'چهارم دبستان', 'پنجم دبستان', 'ششم دبستان'] + + doc = fitz.open(pdf_path) + try: + text = doc[0].get_text() + finally: + doc.close() + + grade = next((g for g in grades if g in text), None) + return start_pages.get(grade, 7), grade + + +_VISION_JSON_TEMPLATE = """ +{ + "grade": "", + "chapter": "", + "problemTypes": [ + {"type": "", "keywords": [], "generalSolution": "", + "exampleFromImage": {"problem": "", "solution": ""}} + ] +} +""" + + +def extract_problem_types(image, grade: str, chapter_name: str) -> dict: + """ + یک عکسِ صفحه‌ی کتاب → ساختارِ JSONِ انواعِ مسئله (فارسی)، با مدلِ vision. + خروجی همون شکلی‌ست که uploadِ ریاضیِ تیمِ دیگه هم تولید می‌کرد. + """ + prompt = f""" +You are a specialist elementary math teacher for children aged 8 to 12. +Analyze the uploaded image and extract every unique math problem type for the given chapter. + +For each type produce: type, keywords (3-7, Persian), generalSolution (step-by-step), +exampleFromImage (one real problem from the image + full solution). + +Output must follow EXACTLY this JSON structure: +{_VISION_JSON_TEMPLATE} + +Rules: +- Everything in Persian (Farsi). +- grade = "{grade}" +- chapter = "{chapter_name}" +- exampleFromImage must be a real problem from the image (not invented). +- Output ONLY the JSON object. No markdown, no backticks. +""" + + buffered = BytesIO() + image.save(buffered, format='JPEG') + img_b64 = base64.b64encode(buffered.getvalue()).decode() + + client = clients.get_openai_client() + response = client.chat.completions.create( + model=settings.OPENAI_VISION_MODEL, + messages=[ + {'role': 'system', 'content': 'You process educational visual content and respond ONLY with valid JSON.'}, + {'role': 'user', 'content': [ + {'type': 'text', 'text': prompt}, + {'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{img_b64}', 'detail': 'high'}}, + ]}, + ], + max_tokens=3000, + temperature=0.1, + ) + raw = (response.choices[0].message.content or '').strip() + if raw.startswith('```json'): + raw = raw[len('```json'):].strip() + if raw.startswith('```'): + raw = raw[3:].strip() + if raw.endswith('```'): + raw = raw[:-3].strip() + return json.loads(raw) + + +def problem_type_to_text(problem_type: dict) -> str: + """یک problemType → متنِ قابلِ embedding (مثلِ doc_text در Rag_backend).""" + example = problem_type.get('exampleFromImage', {}) or {} + keywords = ' '.join(problem_type.get('keywords', []) or []) + return ( + f"Type: {problem_type.get('type', '')}\n" + f"Keywords: {keywords}\n" + f"General solution: {problem_type.get('generalSolution', '')}\n" + f"Example problem: {example.get('problem', '')}\n" + f"Example solution: {example.get('solution', '')}" + ).strip() diff --git a/rag/services/qdrant_store.py b/rag/services/qdrant_store.py new file mode 100644 index 0000000..68bdea2 --- /dev/null +++ b/rag/services/qdrant_store.py @@ -0,0 +1,66 @@ +""" +لایه‌ی نازک روی Qdrant: ساختِ collection، upsert نقاط، و جستجوی برداری. + +برداشتِ پاک‌سازی‌شده از subjects/services/qdrant.py مخزنِ Rag_backend. +هیچ کلاینتِ سطحِ ماژولی ساخته نمی‌شه؛ همه از get_qdrant_client() lazy. +""" +from django.conf import settings + +from . import clients + + +def ensure_collection(name: str, vector_size: int | None = None) -> None: + client = clients.get_qdrant_client() + vector_size = vector_size or settings.OPENAI_EMBEDDING_DIM + existing = {c.name for c in client.get_collections().collections} + if name in existing: + return + from qdrant_client.models import Distance, VectorParams + + client.create_collection( + collection_name=name, + vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE), + ) + + +def upsert_points(collection: str, points: list[dict]) -> int: + """points: [{id, vector, payload}, ...]""" + if not points: + return 0 + client = clients.get_qdrant_client() + from qdrant_client.models import PointStruct + + structs = [ + PointStruct(id=str(p['id']), vector=p['vector'], payload=p['payload']) + for p in points + ] + client.upsert(collection_name=collection, points=structs, wait=True) + return len(structs) + + +def search(collection: str, query_vector: list[float], *, lesson_filter_value=None, + lesson_filter_key: str | None = None, limit: int = 4) -> list[dict]: + """ + نزدیک‌ترین نقاط به بردارِ پرسش، در صورتِ نیاز محدود به یک درس. + خروجی: [{score, payload}, ...] — payload همون چیزیه که تیمِ دیگه ذخیره + کرده (معمولاً شاملِ summary / lesson_title / lesson_number). + """ + client = clients.get_qdrant_client() + query_filter = None + if lesson_filter_value is not None and lesson_filter_key: + from qdrant_client.models import FieldCondition, Filter, MatchValue + + # دادهٔ تیمِ دیگه lesson_number/chapter را همیشه به‌صورتِ رشته ذخیره + # کرده ("5")، پس مقدار را به رشته تبدیل می‌کنیم تا فیلتر بخورد. + query_filter = Filter(should=[ + FieldCondition(key=lesson_filter_key, match=MatchValue(value=str(lesson_filter_value))), + FieldCondition(key=lesson_filter_key, match=MatchValue(value=lesson_filter_value)), + ]) + + response = client.query_points( + collection_name=collection, + query=query_vector, + limit=limit, + query_filter=query_filter, + ) + return [{'score': p.score, 'payload': p.payload or {}} for p in response.points] diff --git a/rag/services/retrieval.py b/rag/services/retrieval.py new file mode 100644 index 0000000..b284f8e --- /dev/null +++ b/rag/services/retrieval.py @@ -0,0 +1,129 @@ +""" +مرحله‌ی retrieval: پرسشِ کاربر → embedding → جستجوی Qdrant → چند خلاصه‌ی +مرتبط به‌عنوانِ context. + +از UnexpectedResponseِ Qdrant (مثلاً collection موجود نیست چون این درس +هنوز آپلود نشده) به‌جای crash، لیستِ خالی برمی‌گردونیم تا لایه‌ی بالا +بتونه پاسخِ «این درس هنوز پوشش داده نشده» بده. +""" +import logging + +from .config import build_collection_name, resolve_grade_title, retrieval_top_k +from .embedding import embed_text +from .known_collections import filter_key_for_subject, is_math_subject, resolve_math_chapter +from .qdrant_store import search + +logger = logging.getLogger(__name__) + + +def resolve_target(subject: str, grade_title: str, grade_id, lesson_number): + """ + (collection, filter_key, filter_value) — مقصدِ دقیقِ retrieval در Qdrant. + + ریاضی با کلیدِ payloadِ `chapter` (رشته‌ی کاملِ فصل) فیلتر می‌شه و شماره‌ی + ساده‌ی فصل به همون رشته تبدیل می‌شه؛ بقیه با `lesson_number` (که در Qdrant + عددِ int ذخیره شده). فقط برای دیباگ/نمایشِ داخلی هم استفاده می‌شه. + """ + grade_title = grade_title or resolve_grade_title(grade_id) + if not subject or not grade_title: + return None, None, None + + collection = build_collection_name(subject, grade_title) + filter_key = filter_key_for_subject(subject) + if is_math_subject(subject): + filter_value = resolve_math_chapter(subject, grade_title, lesson_number) + else: + filter_value = lesson_number + return collection, filter_key, filter_value + + +def retrieve_context(question: str, *, subject: str, grade_id=None, grade_title: str = '', + lesson_number=None, limit: int | None = None) -> tuple[list[dict], dict]: + """ + خروجی: (chunks, meta) + chunks = [{title, summary, score}, ...] (ممکنه خالی باشه، بدونِ خطا) + meta = {collection, filterKey, filterValue, gradeTitle, chunkCount} + فقط برای نمایشِ داخلی/دیباگ (نه برای کاربرِ نهایی). + """ + grade_title = grade_title or resolve_grade_title(grade_id) + collection, filter_key, filter_value = resolve_target(subject, grade_title, grade_id, lesson_number) + meta = { + 'collection': collection, + 'filterKey': filter_key if filter_value is not None else None, + 'filterValue': filter_value, + 'gradeTitle': grade_title, + 'chunkCount': 0, + } + + if collection is None: + logger.info('rag.retrieve: subject/grade ناقص (subject=%r grade=%r) — رد شد', subject, grade_title) + return [], meta + + limit = limit or retrieval_top_k() + + try: + query_vector = embed_text(question) + except Exception: + logger.exception('rag.retrieve: embedding شکست خورد') + raise + + try: + from qdrant_client.http.exceptions import UnexpectedResponse + except ModuleNotFoundError: + UnexpectedResponse = () # type: ignore + + try: + hits = search( + collection, + query_vector, + lesson_filter_value=filter_value, + lesson_filter_key=filter_key if filter_value is not None else None, + limit=limit, + ) + except Exception as exc: # noqa: BLE001 + if UnexpectedResponse and isinstance(exc, UnexpectedResponse) and getattr(exc, 'status_code', None) == 404: + logger.info('rag.retrieve: collection %r موجود نیست', collection) + return [], meta + logger.exception('rag.retrieve: جستجوی Qdrant در collection %r شکست خورد', collection) + raise + + context = [] + for hit in hits: + payload = hit['payload'] + summary = _payload_text(payload) + if not summary: + continue + context.append({ + 'title': _payload_title(payload), + 'summary': summary, + 'score': hit.get('score'), + }) + meta['chunkCount'] = len(context) + return context, meta + + +def _payload_text(payload: dict) -> str: + """ + دو شکلِ payload در دادهٔ تیمِ دیگه هست: + - درس‌های متنی (علوم/اجتماعی): کلیدِ `summary` + - ریاضی: کلیدِ `generalSolution` (+ problem/solution) و بدونِ `summary` + """ + if payload.get('summary'): + return payload['summary'] + parts = [ + payload.get('generalSolution', ''), + payload.get('problem', ''), + payload.get('solution', ''), + ] + joined = '\n'.join(p for p in parts if p).strip() + return joined or payload.get('text', '') + + +def _payload_title(payload: dict) -> str: + return ( + payload.get('lesson_title') + or payload.get('title') + or payload.get('type') + or payload.get('chapter') + or '' + ) diff --git a/rag/services/tutor.py b/rag/services/tutor.py new file mode 100644 index 0000000..4b8c553 --- /dev/null +++ b/rag/services/tutor.py @@ -0,0 +1,35 @@ +""" +نقطه‌ی ورودِ RAG برای ai_tutor. + +ai_tutor.views.generate_tutor_answer وقتی rag_is_configured() باشه این را +صدا می‌زنه؛ در غیرِ این‌صورت به همون پاسخِ stubِ قبلی برمی‌گرده. +""" +import logging + +from .config import rag_is_configured # noqa: F401 (re-export برای ai_tutor) +from .generation import generate_answer +from .retrieval import retrieve_context + +logger = logging.getLogger(__name__) + + +def answer_question(user_question: str, *, subject: str = '', grade_id=None, + lesson_number=None) -> tuple[str, list[str], list[dict]]: + """ + خروجی: (answer, follow_ups, used_chunks) + + used_chunks = [{title, summary, score}, ...] — ممکنه خالی باشه وقتی + subject/lesson_number ارسال نشده یا اون درس هنوز در Qdrant نیست؛ در این + حالت پاسخ به‌صورتِ «تدریسِ عمومی» تولید می‌شه (بدونِ استناد به کتاب). + + استثناهای شبکه/provider اینجا خفه نمی‌شن؛ لایه‌ی بالا (AskAIView) اون‌ها + رو به 503 AIProviderUnavailable تبدیل می‌کنه. + """ + context, _meta = retrieve_context( + user_question, + subject=subject, + grade_id=grade_id, + lesson_number=lesson_number, + ) + answer, follow_ups = generate_answer(user_question, context) + return answer, follow_ups, context diff --git a/rag/tests.py b/rag/tests.py new file mode 100644 index 0000000..d4086a7 --- /dev/null +++ b/rag/tests.py @@ -0,0 +1,279 @@ +""" +تست‌های لایه‌ی RAG. همه‌ی فراخوانی‌های شبکه (OpenAI/GapGPT و Qdrant) mock +می‌شن؛ این تست‌ها بدونِ کلید و بدونِ اینترنت هم سبز می‌مونن. +""" +from types import SimpleNamespace +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from accounts.authentication import issue_access_token +from accounts.models import User + +from .models import LessonChunk +from .services.clients import reset_clients_cache + +RAG_ON = dict( + OPENAI_API_KEY='test-openai', + QDRANT_API_KEY='test-qdrant', + RAG_FORCE_MOCK=False, + RAG_GRADE_TITLE_OVERRIDES={1: 'ششم دبستان'}, +) + + +def _fake_openai(embedding=None, chat_reply='پاسخِ آزمایشیِ مدل.'): + dim = 1536 + vec = embedding or [0.01] * dim + + class _Embeddings: + def create(self, **kwargs): + return SimpleNamespace(data=[SimpleNamespace(embedding=list(vec))]) + + class _Completions: + def create(self, **kwargs): + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content=chat_reply))] + ) + + return SimpleNamespace( + embeddings=_Embeddings(), + chat=SimpleNamespace(completions=_Completions()), + ) + + +class _FakeQdrant: + def __init__(self, points=None): + self._collections = set() + self._points = points or [] + self.upserted = [] + + def get_collections(self): + return SimpleNamespace(collections=[SimpleNamespace(name=n) for n in self._collections]) + + def create_collection(self, collection_name, **kwargs): + self._collections.add(collection_name) + + def upsert(self, collection_name, points, **kwargs): + self._collections.add(collection_name) + self.upserted.extend(points) + + def query_points(self, collection_name, query, limit=4, query_filter=None): + pts = [SimpleNamespace(score=0.9, payload=p) for p in self._points][:limit] + return SimpleNamespace(points=pts) + + +@override_settings(**RAG_ON) +class AskAIRagPathTests(TestCase): + def setUp(self): + cache.clear() + reset_clients_cache() + self.client = APIClient() + self.user = User.objects.create(device_id='dev-rag-1', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + + def _ask(self, body): + return self.client.post( + '/api/ai/ask', body, format='json', + HTTP_AUTHORIZATION=f'Bearer {self.token}', + ) + + def test_ask_with_retrieval_returns_answer_and_used_chunks(self): + qdrant = _FakeQdrant(points=[ + {'summary': 'چرخه‌ی آب شاملِ تبخیر، تراکم و بارش است.', 'lesson_title': 'آب', 'lesson_number': 3}, + {'summary': 'خورشید آب را گرم می‌کند و بخار می‌شود.', 'lesson_title': 'آب', 'lesson_number': 3}, + ]) + with patch('rag.services.clients.get_openai_client', return_value=_fake_openai(chat_reply='آب تبخیر می‌شود.')), \ + patch('rag.services.clients.get_qdrant_client', return_value=qdrant): + resp = self._ask({ + 'userQuestion': 'چرخه‌ی آب چیست؟', + 'gradeId': 1, + 'subject': 'علوم تجربی', + 'lessonNumber': 3, + }) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.data['answer'], 'آب تبخیر می‌شود.') + self.assertEqual(len(resp.data['usedChunks']), 2) + self.assertEqual(resp.data['usedChunks'][0]['title'], 'آب') + + def test_ask_without_subject_skips_retrieval_but_still_answers(self): + with patch('rag.services.clients.get_openai_client', return_value=_fake_openai(chat_reply='پاسخِ عمومی.')), \ + patch('rag.services.clients.get_qdrant_client', return_value=_FakeQdrant()): + resp = self._ask({'userQuestion': 'یک سوالِ کلی'}) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.data['answer'], 'پاسخِ عمومی.') + self.assertEqual(resp.data['usedChunks'], []) + + def test_provider_failure_becomes_503_and_releases_slot(self): + from ai_tutor.models import Conversation, Message + + def boom(*a, **k): + raise ConnectionError('qdrant down') + + with patch('rag.services.clients.get_openai_client', return_value=_fake_openai()), \ + patch('rag.services.retrieval.search', side_effect=boom): + resp = self._ask({ + 'userQuestion': 'سوال', 'gradeId': 1, 'subject': 'علوم تجربی', 'lessonNumber': 3, + }) + self.assertEqual(resp.status_code, 503) + self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 0) + self.assertEqual(Message.objects.filter(conversation__user_id=self.user.id).count(), 0) + + +@override_settings(**RAG_ON) +class IngestTextTests(TestCase): + def setUp(self): + cache.clear() + reset_clients_cache() + self.client = APIClient() + + def test_ingest_text_creates_chunks(self): + qdrant = _FakeQdrant() + reply = '{"summary": "خلاصه‌ی این تکه."}' + with patch('rag.services.clients.get_openai_client', return_value=_fake_openai(chat_reply=reply)), \ + patch('rag.services.clients.get_qdrant_client', return_value=qdrant): + resp = self.client.post( + '/api/rag/ingest/text', + { + 'lessonId': 12, 'gradeId': 1, 'subject': 'علوم تجربی', + 'lessonNumber': 3, 'title': 'آب', + 'text': 'الف' * 900, + }, + format='json', + HTTP_X_INTERNAL_KEY='dev-internal-service-key-change-me', + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertGreater(resp.data['chunksCreated'], 0) + self.assertEqual(LessonChunk.objects.filter(lesson_id=12).count(), resp.data['chunksCreated']) + self.assertTrue(qdrant.upserted) + self.assertEqual(resp.data['collection'], 'علوم تجربی ششم دبستان'.lower()) + + def test_ingest_requires_internal_key(self): + resp = self.client.post( + '/api/rag/ingest/text', + {'lessonId': 1, 'subject': 'علوم تجربی', 'lessonNumber': 1, 'text': 'x'}, + format='json', + ) + # بدونِ X-Internal-Key و بدونِ توکن: DRF بسته به authenticator یا + # 401 می‌ده یا 403 — هر دو یعنی «رد شد». + self.assertIn(resp.status_code, (401, 403)) + + +class RagConfigTests(TestCase): + def test_rag_disabled_without_keys(self): + from rag.services.config import rag_is_configured + + with override_settings(OPENAI_API_KEY='', QDRANT_API_KEY=''): + self.assertFalse(rag_is_configured()) + with override_settings(OPENAI_API_KEY='a', QDRANT_API_KEY='b', RAG_FORCE_MOCK=True): + self.assertFalse(rag_is_configured()) + with override_settings(OPENAI_API_KEY='a', QDRANT_API_KEY='b', RAG_FORCE_MOCK=False): + self.assertTrue(rag_is_configured()) + + +def _parse_sse(streaming_content): + """bytes iterator → [(event, data_str), ...]""" + raw = b''.join(streaming_content).decode('utf-8') + events = [] + for block in raw.split('\n\n'): + block = block.strip() + if not block: + continue + ev, data = None, None + for line in block.splitlines(): + if line.startswith('event: '): + ev = line[7:] + elif line.startswith('data: '): + data = line[6:] + events.append((ev, data)) + return events + + +@override_settings(**RAG_ON) +class AskAIStreamTests(TestCase): + def setUp(self): + cache.clear() + reset_clients_cache() + self.client = APIClient() + self.user = User.objects.create(device_id='dev-stream-1', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + + def _ask_stream(self, body): + return self.client.post( + '/api/ai/ask', {**body, 'stream': True}, format='json', + HTTP_AUTHORIZATION=f'Bearer {self.token}', + ) + + def test_stream_emits_chunks_then_done_and_persists(self): + from ai_tutor.models import Message + + chunks_out, meta = [{'title': 'کسر', 'summary': 'خلاصه‌ی کسر'}], {'collection': 'ریاضی ششم'} + with patch('rag.services.retrieval.retrieve_context', return_value=(chunks_out, meta)), \ + patch('rag.services.generation.stream_answer', return_value=iter(['برای ', 'جمع ', 'کسرها...'])): + resp = self._ask_stream({ + 'userQuestion': 'جمع کسر؟', 'subject': 'ریاضی', 'grade': 'ششم', 'lessonNumber': 2, + }) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp['content-type'], 'text/event-stream') + events = _parse_sse(resp.streaming_content) + kinds = [e for e, _ in events] + self.assertEqual(kinds.count('chunk'), 3) + self.assertEqual(kinds[-1], 'done') + import json as _json + done = _json.loads(events[-1][1]) + self.assertIn('messageId', done) + self.assertEqual(done['usedChunks'][0]['title'], 'کسر') + # پیامِ کاربر و دستیار هر دو ثبت شده و کوتا مصرف شده + self.assertEqual(Message.objects.filter(conversation__user_id=self.user.id, role='assistant').count(), 1) + msg = Message.objects.get(conversation__user_id=self.user.id, role='assistant') + self.assertEqual(msg.text, 'برای جمع کسرها...') + + def test_stream_provider_failure_emits_error_and_releases_slot(self): + from ai_tutor.models import Conversation, Message + + def boom(*a, **k): + raise ConnectionError('provider down') + + with patch('rag.services.retrieval.retrieve_context', side_effect=boom): + resp = self._ask_stream({'userQuestion': 'x', 'subject': 'ریاضی', 'grade': 'ششم', 'lessonNumber': 2}) + events = _parse_sse(resp.streaming_content) + self.assertEqual(events[-1][0], 'error') + self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 0) + self.assertEqual(Message.objects.filter(conversation__user_id=self.user.id).count(), 0) + + +@override_settings(**RAG_ON, RAG_ANSWER_CACHE_TTL=300) +class AskAIAnswerCacheTests(TestCase): + def setUp(self): + cache.clear() + reset_clients_cache() + self.client = APIClient() + self.user = User.objects.create(device_id='dev-cache-1', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + + def _ask(self, body): + return self.client.post( + '/api/ai/ask', body, format='json', HTTP_AUTHORIZATION=f'Bearer {self.token}', + ) + + def test_identical_question_second_time_skips_generation_but_still_persists(self): + from ai_tutor.models import Message + + qdrant = _FakeQdrant(points=[{'summary': 'خلاصه', 'lesson_title': 'کسر', 'lesson_number': 2}]) + body = {'userQuestion': 'سوال یکسان', 'subject': 'ریاضی', 'grade': 'ششم', 'lessonNumber': 2} + + with patch('rag.services.clients.get_openai_client', return_value=_fake_openai(chat_reply='پاسخ ۱')) as oai, \ + patch('rag.services.clients.get_qdrant_client', return_value=qdrant): + r1 = self._ask(body) + calls_after_first = oai.call_count + r2 = self._ask(body) + + self.assertEqual(r1.status_code, 200) + self.assertEqual(r2.status_code, 200) + self.assertEqual(r1.data['answer'], r2.data['answer']) + # درخواستِ دوم نباید کلاینتِ OpenAI رو دوباره صدا بزنه (کش) + self.assertEqual(oai.call_count, calls_after_first) + # ولی پیام و کوتا برای هر دو درخواست ثبت/مصرف شده + self.assertEqual(Message.objects.filter(conversation__user_id=self.user.id, role='user').count(), 2) + self.assertGreater(r1.data['remainingQuota'], r2.data['remainingQuota']) diff --git a/rag/urls.py b/rag/urls.py new file mode 100644 index 0000000..a5a036d --- /dev/null +++ b/rag/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import ChunkListView, IngestPdfView, IngestTextView + +urlpatterns = [ + path('rag/ingest/text', IngestTextView.as_view(), name='rag-ingest-text'), + path('rag/ingest/pdf', IngestPdfView.as_view(), name='rag-ingest-pdf'), + path('rag/chunks', ChunkListView.as_view(), name='rag-chunks'), +] diff --git a/rag/views.py b/rag/views.py new file mode 100644 index 0000000..c5d9cd5 --- /dev/null +++ b/rag/views.py @@ -0,0 +1,157 @@ +import base64 +import binascii +import logging + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework.exceptions import APIException +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.views import APIView + +from payment.permissions import HasInternalServiceKey + +from .models import LessonChunk +from .serializers import ( + ChunkListResponseSerializer, + ChunkSerializer, + IngestPdfRequestSerializer, + IngestResponseSerializer, + IngestTextRequestSerializer, +) +from .services import ingest as ingest_service +from .services.clients import RagNotConfigured + +logger = logging.getLogger(__name__) + +_INTERNAL_KEY_PARAM = OpenApiParameter( + 'X-Internal-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True, + description='کلید مخفیِ فراخوانی داخلی/سرور-به-سرور (settings.INTERNAL_SERVICE_KEY).', +) + + +class RagProviderUnavailable(APIException): + status_code = 503 + default_detail = 'سرویس هوش مصنوعی/بردار موقتاً در دسترس نیست. لطفاً بعداً تلاش کنید.' + default_code = 'rag_provider_unavailable' + + +def _ingest_response(result: dict) -> Response: + return Response(IngestResponseSerializer({ + 'status': result['status'], + 'collection': result['collection'], + 'chunksCreated': result['chunks_created'], + 'chunkIds': result.get('chunk_ids', []), + }).data) + + +class IngestTextView(APIView): + """POST /api/rag/ingest/text — آپلودِ متنِ یک درس، chunk/خلاصه/embedding و ذخیره در DB + Qdrant.""" + + permission_classes = [HasInternalServiceKey] + throttle_scope = 'rag_ingest' + throttle_classes = [ScopedRateThrottle] + + @extend_schema( + request=IngestTextRequestSerializer, + responses=IngestResponseSerializer, + parameters=[_INTERNAL_KEY_PARAM], + tags=['RAG (rag)'], + ) + def post(self, request): + serializer = IngestTextRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + try: + result = ingest_service.ingest_text( + lesson_id=v['lessonId'], + grade_id=v.get('gradeId'), + subject=v['subject'], + lesson_number=v['lessonNumber'], + text=v['text'], + chapter=v.get('chapter'), + title=v.get('title') or '', + ) + except RagNotConfigured as exc: + logger.warning('rag ingest_text: not configured: %s', exc) + raise RagProviderUnavailable(detail=str(exc)) + except Exception: + logger.exception('rag ingest_text failed') + raise RagProviderUnavailable() + return _ingest_response(result) + + +class IngestPdfView(APIView): + """POST /api/rag/ingest/pdf — آپلودِ PDF کتاب، استخراجِ صفحات با vision و ذخیره در DB + Qdrant.""" + + permission_classes = [HasInternalServiceKey] + throttle_scope = 'rag_ingest' + throttle_classes = [ScopedRateThrottle] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + @extend_schema( + request=IngestPdfRequestSerializer, + responses=IngestResponseSerializer, + parameters=[_INTERNAL_KEY_PARAM], + tags=['RAG (rag)'], + ) + def post(self, request): + serializer = IngestPdfRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + + upload = v.get('pdf') + if upload is not None: + pdf_bytes = upload.read() + else: + try: + pdf_bytes = base64.b64decode(v['pdfBase64'], validate=True) + except (binascii.Error, ValueError): + raise RagProviderUnavailable(detail='pdfBase64 معتبر نیست.') + + try: + result = ingest_service.ingest_pdf( + lesson_id=v['lessonId'], + grade_id=v.get('gradeId'), + subject=v['subject'], + lesson_number=v['lessonNumber'], + pdf_bytes=pdf_bytes, + chapter=v.get('chapter'), + title=v.get('title') or '', + chapter_name=v.get('chapterName') or '', + ) + except RagNotConfigured as exc: + logger.warning('rag ingest_pdf: not available: %s', exc) + raise RagProviderUnavailable(detail=str(exc)) + except Exception: + logger.exception('rag ingest_pdf failed') + raise RagProviderUnavailable() + return _ingest_response(result) + + +class ChunkListView(APIView): + """GET /api/rag/chunks — فهرستِ chunkهای ذخیره‌شده (بازبینی)، فیلترِ lessonId/subject.""" + + permission_classes = [HasInternalServiceKey] + + @extend_schema( + responses=ChunkListResponseSerializer, + parameters=[ + _INTERNAL_KEY_PARAM, + OpenApiParameter('lessonId', OpenApiTypes.INT, OpenApiParameter.QUERY, required=False), + OpenApiParameter('subject', OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + ], + tags=['RAG (rag)'], + ) + def get(self, request): + qs = LessonChunk.objects.all() + lesson_id = request.query_params.get('lessonId') + subject = request.query_params.get('subject') + if lesson_id: + qs = qs.filter(lesson_id=lesson_id) + if subject: + qs = qs.filter(subject=subject) + rows = list(qs[:200]) + data = {'count': len(rows), 'results': ChunkSerializer(rows, many=True).data} + return Response(ChunkListResponseSerializer(data).data)