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