diff --git a/.env.example b/.env.example index 2058047..5123284 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,38 @@ KAVENEGAR_API_KEY= # رمزِ ادمینِ Grafana (کاربرِ admin) — فقط برای دسترسیِ محلی GRAFANA_ADMIN_PASSWORD=admin + +# ----------------------------------------------------------------------- +# RAG / AI tutor (اپ rag) — موقعِ تست پر کنید +# ----------------------------------------------------------------------- +# بدونِ OPENAI_API_KEY و QDRANT_API_KEY، مسیرِ RAG غیرفعاله و +# POST /api/ai/ask همون پاسخِ stubِ قبلی رو می‌ده (سازگاریِ عقب‌رو). +# فعلاً از GapGPT (پروکسیِ OpenAI برای ایران) استفاده می‌کنیم. +OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.gapgpt.app/v1 +# اگه بعداً خواستی مستقیم OpenAI واقعی رو تست کنی: کلیدِ واقعی رو این پایین +# پارک کن، بعد فقط دو خطِ بالا رو باهاش عوض کن. هیچ منطقِ سوییچی در کد نیست +# تا ساده و بی‌اشتباه بمونه. +# OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_API_KEY_OPENAI_DIRECT= +OPENAI_CHAT_MODEL=gpt-4o-mini +OPENAI_VISION_MODEL=gpt-4o +# این رو تغییر نده؛ باید با مدلی که تیمِ دیگه باهاش collectionها رو ساخته یکی باشه. +OPENAI_EMBEDDING_MODEL=text-embedding-3-small + +QDRANT_URL=https://qdrant.tadrisino2.ir:443 +QDRANT_API_KEY= + +# نامِ collection = f"{subject} {grade}".lower() . collectionهای موجود روی +# سرورِ تیمِ دیگه (بررسی‌شده): «علوم تجربی سوم» ، «مطالعات اجتماعی چهارم» ، +# «مطالعات اجتماعی ششم» ، «ریاضی سوم» ، «ریاضی ششم» ، «ریاضی هفتم». +# یعنی جزءِ گرید فقط «سوم/چهارم/ششم/هفتم» است (بدونِ «دبستان»). +# +# کلیدِ فیلترِ درس دیگر دستی تنظیم نمی‌شه — rag/services/known_collections.py +# خودش تشخیص می‌ده: ریاضی → `chapter` (و شماره‌ی فصل رو به رشته‌ی دقیق تبدیل +# می‌کنه)، بقیه → `lesson_number`. RAG_QDRANT_LESSON_FILTER_KEY فقط برای +# سازگاریِ عقب‌رو مونده و استفاده نمی‌شه. +RAG_COLLECTION_NAME_TEMPLATE={subject} {grade} +RAG_RETRIEVAL_TOP_K=4 +# RAG_FORCE_MOCK=1 یعنی حتی با وجودِ کلیدها هم مسیرِ RAG خاموش بمونه. +RAG_FORCE_MOCK= diff --git a/config/settings.py b/config/settings.py index 28ee39d..ea185e3 100644 --- a/config/settings.py +++ b/config/settings.py @@ -18,6 +18,17 @@ from django.core.exceptions import ImproperlyConfigured # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +# مقادیرِ .env رو داخلِ os.environ می‌ذاره تا os.environ.get های پایین کار کنن. +# override=False: env واقعیِ سیستم و مقادیرِ docker-compose همچنان اولویت دارن، +# پس رفتارِ پروداکشن/داکر عوض نمی‌شه؛ این فقط برای اجرای محلی (بدون export) و +# کلیدهای RAG (accounts/... , OPENAI_API_KEY, QDRANT_API_KEY, ...) هست. +try: + from dotenv import load_dotenv + + load_dotenv(BASE_DIR / '.env', override=False) +except ModuleNotFoundError: + pass + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ @@ -83,6 +94,10 @@ INSTALLED_APPS = [ # Tadrisino apps (مدلِ مینیمالِ ذخیره‌سازیِ کمپین‌های پیامی، بدونِ منطقِ ارسال) 'campaigns', + + # Tadrisino apps (لایه‌ی RAG: ذخیره‌ی chunkهای درسی + بازیابی از Qdrant، + # مصرف‌کننده‌ش ai_tutor.generate_tutor_answer است) + 'rag', ] MIDDLEWARE = [ @@ -239,6 +254,7 @@ REST_FRAMEWORK = { 'guest_session': '20/hour', 'bazaar_verify': '10/hour', 'ai_ask': '20/hour', + 'rag_ingest': '30/hour', 'purchase': '10/hour', 'token_refresh': '30/hour', 'question_answer': '60/min', @@ -355,6 +371,52 @@ BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '') BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '') BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2') +# ----------------------------------------------------------------------- +# RAG / AI provider (اپ rag + ai_tutor.generate_tutor_answer) +# ----------------------------------------------------------------------- +# provider همون OpenAI است ولی از طریقِ پروکسیِ ایرانیِ GapGPT (base_url). +# بدونِ OPENAI_API_KEY و QDRANT_API_KEY، rag_is_configured() برابرِ False +# می‌شه و generate_tutor_answer به همون پاسخِ stubِ قبلی برمی‌گرده (پس +# تست‌های موجود و اجرای بدونِ کلید دست‌نخورده کار می‌کنن). +OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '') +OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://api.gapgpt.app/v1') +OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4o-mini') +OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4o') +# نباید تغییر کنه مگر اینکه collectionهای Qdrant هم با همون مدلِ جدید بازساخته بشن. +OPENAI_EMBEDDING_MODEL = os.environ.get('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small') +OPENAI_EMBEDDING_DIM = int(os.environ.get('OPENAI_EMBEDDING_DIM', '1536')) +OPENAI_TIMEOUT_SECONDS = int(os.environ.get('OPENAI_TIMEOUT_SECONDS', '60')) + +QDRANT_URL = os.environ.get('QDRANT_URL', 'https://qdrant.tadrisino2.ir:443') +QDRANT_API_KEY = os.environ.get('QDRANT_API_KEY', '') + +# نامِ collection در دادهٔ تیمِ دیگه = f"{subject} {grade}".lower() . کلیدِ +# فیلترِ درس خودکار انتخاب می‌شه (rag/services/known_collections.py): ریاضی → +# `chapter` ، بقیه → `lesson_number`. +RAG_COLLECTION_NAME_TEMPLATE = os.environ.get('RAG_COLLECTION_NAME_TEMPLATE', '{subject} {grade}') +# نگاشتِ اختیاریِ gradeId → عنوانِ دقیقِ گرید همان‌طور که در نامِ collection +# تیمِ دیگه اومده (مثلاً {6: 'ششم دبستان'})؛ اگه خالی باشه از content.Grade.title +# استفاده می‌شه. کاربر قبلاً با نامِ نادرستِ گرید به مشکل خورده بود. +RAG_GRADE_TITLE_OVERRIDES = {} +RAG_RETRIEVAL_TOP_K = int(os.environ.get('RAG_RETRIEVAL_TOP_K', '4')) +RAG_FORCE_MOCK = os.environ.get('RAG_FORCE_MOCK', '') == '1' + +# کشِ پاسخِ نهایی برای سوالِ عیناً تکراری روی همون (collection + فصل + متنِ +# سوال). فقط embedding/جستجو/تولید رو رد می‌کنه؛ کوتا/idempotency/ثبتِ پیام +# مثلِ همیشه اجرا می‌شن. ۰ یعنی خاموش. TTL کوتاه چون دادهٔ Qdrant ممکنه +# به‌روز بشه. +RAG_ANSWER_CACHE_TTL = int(os.environ.get('RAG_ANSWER_CACHE_TTL', '1800')) + +# در اجرای تست‌ها همیشه RAG خاموش است تا هیچ تستی به OpenAI/Qdrant واقعی +# نزند (سرعت + پایداری). تست‌هایی که مسیرِ RAG را می‌آزمایند خودشان با +# override_settings + mockِ کلاینت‌ها این را برمی‌گردانند (rag/tests.py). +import sys as _sys + +if 'test' in _sys.argv: + RAG_FORCE_MOCK = True + RAG_ANSWER_CACHE_TTL = 0 + + # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field diff --git a/config/urls.py b/config/urls.py index 989e0bb..4728de9 100644 --- a/config/urls.py +++ b/config/urls.py @@ -51,6 +51,7 @@ urlpatterns = [ path('api/', include('content.urls')), path('api/', include('progress.urls')), path('api/', include('ai_tutor.urls')), + path('api/', include('rag.urls')), # اپ‌های فاز ۲ / بخش ۱ (subscription, payment, discount) path('api/', include('subscription.urls')), diff --git a/requirements.txt b/requirements.txt index e7c788e..2f88698 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,16 @@ djangorestframework==3.18.0 drf-spectacular==0.30.0 requests==2.33.1 redis==5.2.1 +python-dotenv==1.0.1 + +# RAG / AI tutor (اپ rag). کلاینت‌ها lazy import می‌شن؛ نبودِ این‌ها فقط +# مسیرِ RAG رو از کار می‌ندازه، نه بقیه‌ی پروژه رو. +openai>=1.40,<3 +qdrant-client>=1.12 + +# فقط برای ingestِ PDF (اختیاری). pdf2image به poppler سیستمی نیاز داره +# (macOS: brew install poppler). نبودشون فقط endpointِ /api/rag/ingest/pdf +# رو با خطای صریح از کار می‌ندازه. +pymupdf>=1.24 +pdf2image>=1.17 +pillow>=10