new app — cleaned-up RAG pipeline (Qdrant retrieval + GPT), ported from Rag_backend

Self-contained app; wired into settings/urls in the next commit. Data
stays in the parallel team's live Qdrant — nothing is copied into our DB.

apps.py
  - RagConfig.
models.py
  - LessonChunk: DB-of-record for one summarized lesson chunk
    (original_text + summary + embedding_vector) mirrored as a single
    Qdrant point; loose lesson_id/grade_id ints, no cross-app FK.
    LessonChunkImage for future multimodal lessons.
migrations/0001_initial.py
  - initial tables for the two models.
services/clients.py
  - lazy, lru_cache'd OpenAI + Qdrant clients (Rag_backend builds them at
    import time and crashes without keys). RagNotConfigured raised when a
    key/lib is missing; Qdrant client uses check_compatibility=False for
    the 1.19-client / 1.17-server minor version gap.
services/config.py
  - rag_is_configured(), collection-name template, grade-title resolution
    (RAG_GRADE_TITLE_OVERRIDES -> content.Grade.title), retrieval top-k.
services/known_collections.py
  - exact live Qdrant catalog: 6 collection names, math chapter-number ->
    exact "فصل N: ..." string tables per (subject, grade), and the
    علوم/اجتماعی lesson-number -> title maps. filter_key_for_subject()
    picks `chapter` for math, `lesson_number` otherwise;
    resolve_math_chapter() maps a plain chapter number (Persian or Latin
    digits) to the stored string.
services/embedding.py
  - embed_text() pinned to OPENAI_EMBEDDING_MODEL (text-embedding-3-small,
    1536d) so vectors match the other team's existing collections.
services/chunking.py
  - split_text_into_chunks(), summarize_chunk() (gpt-4o-mini, JSON out);
    plain exceptions instead of ninja HttpError.
services/qdrant_store.py
  - ensure_collection(), upsert_points(), search(): the lesson filter
    matches the value both as str and native, since Qdrant stores
    lesson_number as int and chapter as string.
services/retrieval.py
  - resolve_target() -> (collection, filter_key, filter_value), reused by
    the cache key and debug block.
  - retrieve_context() -> (chunks, meta): embed -> Qdrant search scoped to
    one lesson/chapter -> top-k. Math payloads (no `summary`) fall back to
    generalSolution/problem/solution; a 404 collection returns empty.
services/generation.py
  - generate_answer() and stream_answer() (token iterator for SSE);
    context-grounded Persian prompt that explains from retrieved text
    rather than refusing.
services/pdf.py
  - textbook PDF -> page images -> gpt-4o vision problem-type extraction
    (adapted from Rag_backend mathapp); returns dicts, no on-disk JSON.
    pymupdf/pdf2image optional — missing libs raise RagNotConfigured.
services/ingest.py
  - ingest_text() / ingest_pdf(): chunk -> summarize -> embed -> persist
    LessonChunk + upsert Qdrant, in one transaction.
services/answer_cache.py
  - short-TTL cache of the final answer keyed by
    sha1(collection | filter_value | normalized-question).
services/tutor.py
  - thin answer_question() (retrieve -> generate) for direct callers.
serializers.py, urls.py, views.py
  - POST /api/rag/ingest/text, POST /api/rag/ingest/pdf (HasInternalServiceKey,
    throttle scope rag_ingest), GET /api/rag/chunks review list.
    RagProviderUnavailable -> clean 503.
admin.py
  - LessonChunk admin with an inline for its images.
management/commands/rag_catalog.py
  - read-only live Qdrant listing (--values, --json); never writes.
tests.py
  - mocked-client tests: ask-with-retrieval, ingest, SSE stream, answer
    cache, and rag_is_configured() gating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 02:29:54 +03:30
parent 38999f3541
commit ac93e17305
26 changed files with 1661 additions and 0 deletions

0
rag/__init__.py Normal file
View File

22
rag/admin.py Normal file
View File

@@ -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')

6
rag/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class RagConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'rag'

View File

View File

View File

@@ -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}")

View File

@@ -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')),
],
),
]

View File

63
rag/models.py Normal file
View File

@@ -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}"

53
rag/serializers.py Normal file
View File

@@ -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)

0
rag/services/__init__.py Normal file
View File

View File

@@ -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())

92
rag/services/chunking.py Normal file
View File

@@ -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

55
rag/services/clients.py Normal file
View File

@@ -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()

44
rag/services/config.py Normal file
View File

@@ -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))

18
rag/services/embedding.py Normal file
View File

@@ -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

View File

@@ -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)

138
rag/services/ingest.py Normal file
View File

@@ -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],
}

View File

@@ -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

126
rag/services/pdf.py Normal file
View File

@@ -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()

View File

@@ -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]

129
rag/services/retrieval.py Normal file
View File

@@ -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 ''
)

35
rag/services/tutor.py Normal file
View File

@@ -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

279
rag/tests.py Normal file
View File

@@ -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'])

9
rag/urls.py Normal file
View File

@@ -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'),
]

157
rag/views.py Normal file
View File

@@ -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)