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:
0
rag/__init__.py
Normal file
0
rag/__init__.py
Normal file
22
rag/admin.py
Normal file
22
rag/admin.py
Normal 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
6
rag/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RagConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'rag'
|
||||
0
rag/management/__init__.py
Normal file
0
rag/management/__init__.py
Normal file
0
rag/management/commands/__init__.py
Normal file
0
rag/management/commands/__init__.py
Normal file
77
rag/management/commands/rag_catalog.py
Normal file
77
rag/management/commands/rag_catalog.py
Normal 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}")
|
||||
50
rag/migrations/0001_initial.py
Normal file
50
rag/migrations/0001_initial.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
rag/migrations/__init__.py
Normal file
0
rag/migrations/__init__.py
Normal file
63
rag/models.py
Normal file
63
rag/models.py
Normal 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
53
rag/serializers.py
Normal 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
0
rag/services/__init__.py
Normal file
42
rag/services/answer_cache.py
Normal file
42
rag/services/answer_cache.py
Normal 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
92
rag/services/chunking.py
Normal 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
55
rag/services/clients.py
Normal 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
44
rag/services/config.py
Normal 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
18
rag/services/embedding.py
Normal 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
|
||||
98
rag/services/generation.py
Normal file
98
rag/services/generation.py
Normal 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
138
rag/services/ingest.py
Normal 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],
|
||||
}
|
||||
102
rag/services/known_collections.py
Normal file
102
rag/services/known_collections.py
Normal 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
126
rag/services/pdf.py
Normal 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()
|
||||
66
rag/services/qdrant_store.py
Normal file
66
rag/services/qdrant_store.py
Normal 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
129
rag/services/retrieval.py
Normal 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
35
rag/services/tutor.py
Normal 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
279
rag/tests.py
Normal 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
9
rag/urls.py
Normal 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
157
rag/views.py
Normal 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)
|
||||
Reference in New Issue
Block a user