صفحههای فرانت خوانده شدند و با API مقایسه شدند. ساختارِ ما از کتابِ واقعی
درآمده و درستتر است، پس مدل عوض نشد — ولی چند چیز کم داشت.
مهمترینش یک باگ بود: /api/home پارامتر را `grade` میخواند و کلاینتی که به
فرانت فرستادیم `gradeId` میفرستد. پارامترِ ناشناخته بیصدا نادیده گرفته
میشد، یعنی کاربر پایهای را انتخاب میکرد و نقشهی پایهی دیگری میدید
بدونِ هیچ خطایی. حالا هر دو نام پذیرفته میشوند (`gradeId` نامِ درست است،
با بقیهی API یکی). تستِ سرتاسریِ قبلی این را نگرفته بود چون home را بدونِ
آرگومان صدا زده بود.
/api/grades حالا lessonCount میدهد. فهرستِ پایههای فرانت تا دوازدهم است
ولی محتوای ما تا نهم؛ بدونِ این عدد، کاربر پایهی خالی را انتخاب میکند و
صفحهی خالی میبیند بیآنکه بفهمد چرا.
GET /api/nodes/{id}/lessons اضافه شد. /api/home فقط شناسهی درسها را
میدهد، ولی نقشهی مهارت برای هر قدم عنوان، نوع، زیرنویس و «انجام شد» لازم
دارد — بدونش فرانت باید برای هر درس یک intro جدا میگرفت.
Lesson حالا kind (آزمون/ویدیو) و متنِ سهتکه دارد: توضیح، مثال، حلِ
گامبهگام. ویدیو از قبل سوالِ بازِ محصول بود و فرانت آیتمش را دارد؛ حالا
API میتواند بیانش کند، حتی اگر محتوایش بعداً بیاید. Unit یک blurb گرفت و
Node آیکون و رنگ، تا فرانت رنگها را هاردکد نکند. هیچکدام اجباری نیست، پس
محتوای موجود دستنخورده کار میکند.
PATCH /api/user/profile حالا age را هم میپذیرد (صفحهی ثبتنامِ فرانت سن
میپرسد). ذخیرهسازی همچنان birthYear است چون کهنه نمیشود؛ این فقط ورودی
را ترجمه میکند تا دو طرف حسابِ متفاوت درنیاورند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
دو شکاف که وقتی آدمهای دیگر به پروژه وصل میشوند مهمتر میشوند.
کوکیِ نشستِ پنلِ ادمین و توکنِ CSRF بدونِ پرچمِ secure رد و بدل میشدند.
احرازِ هویتِ خودِ API کوکی نیست و اینها فقط برای ادمیناند — یعنی دقیقاً
همانجایی که اعتبارنامهی واقعی وارد میشود. حالا خارج از DEBUG امناند، و
SECURE_PROXY_SSL_HEADER هم اضافه شد چون TLS روی nginx تمام میشود و بدونش
جنگو درخواست را HTTP میبیند و کوکیِ امن اصلاً ست نمیشود.
SECURE_SSL_REDIRECT و HSTS عمداً روشن نشدند: اولی پشتِ پراکسیِ بدتنظیم
حلقهی ریدایرکت میسازد و کلِ سایت را میخواباند، دومی در مرورگرِ کاربر کش
میشود و عملاً برگشتناپذیر است. هر دو تصمیمِ زیرساختیاند نه کد.
/api/health حالا ترافیک را هم گزارش میدهد: تعدادِ کاربر، فعالهای ۲۴ ساعتِ
اخیر، و زمانِ آخرین فعالیت. تا امروز هیچ راهی نبود از بیرون بفهمیم فرانت
واقعاً به سرور رسیده یا نه — باید منتظر میماندیم کسی بگوید. فقط شمارش و
زمان؛ تست جداگانه بررسی میکند هیچ شماره یا نامی بیرون نرود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
دو حالتی که فقط روی سرورِ واقعی پیش میآیند، نه اینجا.
اسکریپتهای قدیمیِ activate به PS1 بدونِ محافظ دست میزنند و زیرِ set -u با
«unbound variable» کلِ دیپلوی را میخوابانند — venv اینجا سالم است ولی venv
سرور لزوماً نه، پس set -u دورِ همان یک خط موقتاً خاموش میشود.
بررسیِ سلامت فقط 127.0.0.1:8000 را میزد. اگر gunicorn روی سوکت یا پورتِ
دیگری باشد، دیپلویِ کاملاً موفق هم با پیامِ خطا تمام میشد. حالا آدرسِ
عمومی هم امتحان میشود و در پایان میگوید کدام آدرسها را زده.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
پیشفرضهای دیروز روی یک سرورِ تازه کار میکردند ولی روی سرورِ واقعی نه، و
تستِ با .env واقعی نشانش داد: هر دو حالتِ شایع خنثیشان میکرد.
مقدارِ موجود ولی ناقص: .env سرور از قبل
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 دارد. چون مقدار خالی نبود، کاملاً
جایگزینِ پیشفرض میشد و دامنهی api.math.tadrisino2.ir باز هم غایب میماند —
یعنی همان «Disallowed Host» قبلی، و باز هم ویرایشِ دستیِ فایل. حالا
دامنههای خودمان به مقدارِ محیطی اضافه میشوند نه جایگزینش؛ همین برای
CSRF_TRUSTED_ORIGINS و CORS_ALLOWED_ORIGINS هم اعمال شد.
مقدارِ موجود ولی خالی: PUBLIC_BASE_URL در .env هست و خالی است، و
os.environ.get فقط وقتی کلید *نباشد* پیشفرض میدهد. نتیجهاش paymentUrl
نسبی و callbackِ خالیِ زرینپال بود — خرابیای که فقط موقعِ اولین پرداختِ
واقعی معلوم میشود. حالا مقدارِ خالی هم به پیشفرض میافتد.
این سهتا همانهاییاند که تا امروز مسئولِ سرور باید دستی به .env اضافه
میکرد و چون در .env.example هم نبودند، هر بار از نو کشف میشدند. حالا در
همان فایل مستند شدهاند و تست نگهبانشان است.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
بازبینیِ کارِ امروز، سه مورد پیدا کرد.
/api/health وقتی مهاجرتِ اعمالنشده وجود داشت میشکست: کدِ فهرستکردنِ
مهاجرتها به migration.key تکیه میکرد که اصلاً وجود ندارد (فیلدها app_label
و name هستند). یعنی دقیقاً در همان حالتی که این بررسی برایش ساخته شده،
بهجای نامِ مهاجرتها فقط AttributeError برمیگرداند. حالا تستی دارد که
مهاجرتِ معلق را شبیهسازی میکند — چیزی که نبودش باعث شد اولین بار غلط بماند.
وارد کردنِ سوال با replace عددِ غلط گزارش میکرد: delete() در جنگو مجموعِ
همهی ردیفهای آبشاری را میدهد، پس «۵ سوال پاک شد» در واقع گزینهها و
پاسخها را هم میشمرد.
deploy.sh بررسیِ متغیرهای محیطیاش با تنظیماتِ جدید ناسازگار شده بود:
متغیرهایی را اجباری میدانست که حالا پیشفرض دارند، و فقط شل را میخواند نه
.env — یعنی روی سرورِ درست هم دیپلوی را رد میکرد. حالا بهجای تکرارِ منطقِ
تنظیمات، از خودِ جنگو میپرسد چه چیزی مؤثر است و گزارش میدهد.
کوچکتر: بررسیِ سلامت یک درخواست میزند نه دو تا، و شکستِ ریاستارت دیگر
اسکریپت را بیصدا تمام نمیکند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
دو فایلِ نمونه یعنی دو جا که باید همزمان بهروز بمانند، و همان چیزی بود که
قبلاً باعث شد نمونه عقب بماند و کسی که سرور را بالا میآورد نداند چه بنویسد.
حالا یک فایل، هم برای ماشینِ محلی هم برای سرور.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
فایلِ نمونهی قبلی هنوز چند جای خالی داشت که کسی باید پرشان میکرد، و دو بار
همین باعثِ رفتوبرگشت شد. حالا فقط چیزهایی جای خالیاند که واقعاً فقط آن طرف
میداند: کلیدهای سرویسهای بیرونی و نامِ سرویسِ systemd.
بقیه از قبل تصمیم گرفته شدهاند و اگر فایل خالی هم باشد سرویس بالا میآید.
حالتِ پیشفرض عمداً مرحلهی توسعه است (DEBUG=1) با ریسکهایش نوشته، نه
پروداکشن — چون وضعیتِ امروزِ پروژه همین است و فایلی که وضعیتِ واقعی را
توصیف نکند دوباره حدسزدنی میشود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
پیدا کردنِ «سوالهای فلان مبحث» در فهرستِ پیشفرضِ جنگو عملاً ممکن نبود: باید
بینِ چند صفحهی جدا با دراپداونهای هزارتایی بالا و پایین میرفتی.
/admin/content/question/browse/ چهار ستونِ آبشاری دارد — پایه → فصل → درس →
مبحث — و هر ستون فقط وقتی پر میشود که قبلی انتخاب شده باشد، پس هیچوقت
فهرستِ هزارتایی جلوی چشم نیست. تهاش سوالهای همان مبحث با گزینههایشان
میآیند و از همانجا ویرایش یا اضافه میشوند.
کنارِ هر سوال دو عددِ تصمیمساز: گزارشهای بازِ کاربران، و درصدِ پاسخِ غلط.
درصد زیرِ ۵ پاسخ نشان داده نمیشود — یک نمونهی کوچک سوالِ سالم را
«مشکلدار» جلوه میدهد. همین دو ستون به فهرستِ سوالها هم اضافه شدند.
content.QuestionReport و POST /api/questions/{id}/report تازهاند: تنها
بازخوردی که تا امروز جمع میشد مالِ چتِ دستیار بود، و روی سوالهای مسیرِ
آموزشی هیچ راهی نبود که کاربر بگوید گزینهی درست غلط است. گزارشِ بازِ تکراریِ
همان کاربر ردیفِ تازه نمیسازد تا دابلکلیک آمار را بههم نزند.
وابستگیِ تازهای اضافه نشد: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سه بار پشتِ سر هم همین الگو تکرار شد: یک متغیرِ محیطی ست نبود، جنگو موقعِ
بالا آمدن مرد، و از بیرون فقط «502 Bad Gateway» دیده شد. کسی که سرور را بالا
میآورد هیچ سرنخی نداشت، و تشخیص هر بار یک رفتوبرگشتِ یکروزه بود.
ALLOWED_HOSTS و CSRF_TRUSTED_ORIGINS حالا دامنههای شناختهشدهی خودمان را
پیشفرض دارند. CSRF مخصوصاً مهم است: بدونش ورود به پنلِ ادمین از پشتِ پراکسیِ
HTTPS «CSRF verification failed» میدهد و علتش از پیام معلوم نیست.
SECRET_KEY اگر ست نباشد، در پروداکشن یک کلیدِ تصادفیِ واقعی ساخته و در
.django_secret_key نگه داشته میشود (۰۶۰۰، خارج از گیت). همانقدر
غیرقابلِ حدس، بدونِ یک قدمِ دستیِ دیگر؛ و چون روی دیسک میماند، ریاستارت
نشستهای کاربران را باطل نمیکند.
CACHE_URL نبودن دیگر مانعِ بالا آمدن نیست. مشترک نبودنِ کش بینِ workerها یک
ضعفِ واقعیست ولی دلیلِ خوبی برای خوابیدنِ کلِ سرویس نیست؛ حالا بالا میآید و
/api/health صریحاً هشدار میدهد. تستِ مربوطه هم از «باید بالا نیاید» به
«باید بالا بیاید ولی بیصدا نماند» تغییر کرد.
متغیرِ محیطی همچنان بر همهی این پیشفرضها اولویت دارد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سوالها از قبل فلهای وارد میشدند ولی دو شکاف مانده بود: افزودنِ تکی عملاً
غیرقابلِ استفاده بود، و خودِ برنامهی درسی هیچ راهی جز ترمینالِ سرور نداشت.
افزودنِ تکیِ سوال: انتخابِ درس یک دراپداونِ ۱۳۰۷تایی بود. حالا autocomplete
است و بر اساسِ عنوانِ درس، فصل و پایه جستجو میشود.
برنامهی درسی (Grade → Unit → Node → Lesson) حالا از /admin/content/grade/
وارد و خارج میشود. تا امروز رساندنش به سرور یعنی اجرای rag_import_collection
و بعد seed_curriculum_from_textbook روی همان سرور — یعنی وابستگی به Qdrant و
به آدمی با دسترسیِ SSH. حالا یکبار لوکال ساخته میشود و فایلش بالا میرود.
همسانسازی با عنوان است نه با id: idها بینِ دو دیتابیس یکی نیستند و تکیه بر
آنها یعنی وارد کردن روی سرور محتوای بیربط را بازنویسی میکند.
وارد کردنِ برنامهی درسی عمداً چیزی پاک نمیکند و idempotent است — یک فایلِ
ناقص نباید محتوای سرور را از بین ببرد، و اجرای دوباره نباید چیزی را دو بار
بسازد. (وارد کردنِ سوالها اما replace دارد، چون جایگزینیِ سوالهای یک درس
کارِ رایجی است.)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سه حالتِ خراب که هیچکدام در لاگِ سرور ردی نمیگذارند و فقط وقتی کشف میشدند
که یک آدم از بیرون اپ را دستی امتحان کند.
CORS_ALLOWED_ORIGINS فقط از env خوانده میشد و با DEBUG روشن به localhost
محدود میماند. یعنی بکاندِ کاملاً سالم، ولی مرورگر همهی درخواستهای
math.tadrisino2.ir را بلاک میکند — خطایی که سمتِ سرور دیده نمیشود و فقط در
کنسولِ مرورگر پیداست. origin های شناختهشده حالا پیشفرضِ داخلِ کدند تا یک
متغیرِ فراموششده اتصال را نشکند؛ env همچنان بازنویسیشان میکند.
/api/health حالا warnings هم میدهد. مهمترینش: با DEBUG روشن روی سرورِ عمومی،
DEV_MASTER_OTP یعنی هر کسی با کدِ 0000 میتواند بهجای هر شمارهای وارد شود.
این تا امروز فقط با خواندنِ تنظیماتِ سرور معلوم میشد، و ما به سرور دسترسی
نداریم. allowedOrigins هم برگردانده میشود تا مشکلِ CORS از بیرون قابلِ
تشخیص باشد.
deploy.sh قبل از دست زدن به دیتابیس متغیرهای لازم را بررسی میکند و با پیامِ
روشن میایستد، بهجای اینکه وسطِ کار بشکند و 502 بدهد.
.env.production.example همانها را یکجا مستند میکند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
تا الان تنها راهِ گذاشتنِ سوال روی یک سرور، زدنِ دستور در ترمینالِ همان سرور
بود. یعنی هر تغییرِ محتوایی به کسی که SSH دارد وابسته میشد، تاریخچه نداشت و
برگشتپذیر نبود. محتوا داده است نه کد؛ باید با رابطِ کاربری ویرایش شود.
در /admin/content/question/ دکمهی «وارد کردنِ سوالها از فایل» اضافه شد، و
اکشنِ «دریافتِ فایلِ سوالهای انتخابشده» خروجی میدهد. خروجی دقیقاً همان قالبی
است که ورودی میپذیرد، پس چرخهی «بگیر → ویرایش کن → برگردان» بسته است و برای
گرفتنِ نمونه هم لازم نیست کسی مستندات بخواند.
قالب عمداً ساده است (lessonId، prompt، choices، correct) تا کسی که برنامهنویس
نیست هم بتواند در ویرایشگرِ متن درستش کند.
اعتبارسنجی قبل از هر نوشتنی کامل انجام میشود و کلِ فایل در یک تراکنش است:
فایلِ نامعتبر هیچ چیزی نمینویسد. وارد کردنِ نیمهکاره بدترین حالت بود، چون
کسی که فایل را داده فکر میکند کار شده ولی درس نصفه پر است.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
بالا آوردنِ نسخهی جدید تا الان چند کارِ دستی به ترتیبِ درست بود (pull، نصبِ
وابستگیها، مهاجرت، استاتیک، ریاستارت) و فراموش شدنِ هر کدام اپ را میخواباند
با خطایی که از بیرون فقط «502 Bad Gateway» دیده میشد. همین الان هم سرور
دقیقاً به همین دلیل خوابیده: کامیتِ ac1b1af پکیجِ django-cors-headers را اضافه
کرد، و بدونِ pip install جنگو با ModuleNotFoundError بالا نمیآید.
deploy.sh همه را به ترتیب و idempotent انجام میدهد. pip install همیشه اجرا
میشود، نه فقط وقتی requirements عوض شده — تشخیصِ «عوض شده یا نه» خودش یک جای
فراموششدنیِ دیگر است. آخرِ کار خودش سلامت را بررسی میکند و اگر بالا نیامده
باشد علتهای رایج را چاپ میکند، بهجای اینکه کسی بعداً از بیرون کشفش کند.
GET /api/health وضعیت را بدونِ احراز و بدونِ SSH بیرون میدهد: دیتابیس،
مهاجرتهای اعمالنشده، کش، و شمارشِ پایه/درس/سوال روی همان سرور. مهمترینش
مهاجرتِ اعمالنشده است — حالتی که اپ ظاهراً بالا میآید و بعد زیرِ بارِ واقعی
با خطای ستونِ ناموجود میشکند. عمداً هیچ سکرتی برنمیگرداند، فقط بله/خیرِ
«تنظیم شده».
۵۰۳ برمیگرداند وقتی دیتابیس/کش/مهاجرت خراب است تا پایشِ خودکار بتواند رویش
حساب کند؛ نبودِ محتوا خرابی حساب نمیشود ولی گزارش میشود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
خطاهای این پروژه خاموشاند: نامِ اشتباهِ collection یا فیلترِ نخورده نتیجهی
خالی میدهد نه استثنا، و دستیار فقط بدتر جواب میدهد. این تستها نگهبانِ
همانجاهایند.
TextbookLayoutTests بررسی میکند رشتهی هر فصل بینِ ingest و retrieval یکی
باشد و بازهی صفحهها پیوسته بماند. IngestBookResumeTests مطمئن میشود
صفحهی بیمسئله دوبار پول نمیگیرد، صفحهی خطادار در اجرای بعد دوباره
سراغش میروند، و upsertِ شکستخورده rollback میشود.
LessonContextResolutionTests نگهبانِ نرمالسازیِ «پایه نهم» → «ریاضی نهم»
است و اینکه مسیرِ gradeId و مسیرِ lessonId به یک collection برسند.
GradeAwarePromptTests جلوی برگشتنِ لحنِ دبستانی برای پایههای بالا را
میگیرد. FollowUpStaysGroundedTests پیامِ دومِ یک گفتگو را هم مستند نگه
میدارد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سینیور خواست زودتر به فرانتاند وصل شویم تا از آنجا هم تست شود. اینها
سمتِ ما لازم بودند:
CORS با allowlist صریح (نه ALLOW_ALL): احرازِ هویتِ ما توکنِ Bearer در هدر
است، پس origin ناشناس نباید از مرورگرِ کاربر به API ما بزند.
CORS_ALLOW_CREDENTIALS عمداً خاموش میماند — کوکیای در کار نیست و روشن
کردنش فقط سطحِ حمله اضافه میکند. CorsMiddleware بالاتر از CommonMiddleware
گذاشته شد وگرنه ریدایرکتِ APPEND_SLASH و خطای ۴۰۰ هدرِ CORS نمیگیرند و در
مرورگر بهشکلِ خطای مبهمِ شبکه دیده میشوند.
PATCH /api/user/profile اضافه شد؛ تا پیش از این هیچ راهی برای ثبتِ نامِ
کاربر نبود. GET /api/home بدونِ پارامتر حالا پایهی ذخیرهشدهی کاربر را
برمیگرداند و POST /api/ai/ask فقط با lessonId کار میکند — هر دو برای
اینکه فرانت لازم نباشد پایه را خودش نگه دارد. questionCount به
lessons/{id}/intro اضافه شد.
docs/frontend/api.ts و README.md کلاینتِ تایپدار و راهنمای اتصالاند، از
روی OpenAPI واقعیِ همین سرویس تولید شدهاند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
مهمترین خواستهی سینیور این بود که بسنجیم پاسخِ AI واقعاً یاد میدهد یا نه.
برای سنجیدن، اول باید پاسخها قابلِ ردیابی میشدند.
پرامپتها پایهآگاه شدند: تا پیش از این «معلمِ دبستانی» برای پایه نهم هاردکد
بود، هم در تولیدِ پاسخ و هم در vision — یعنی دانشآموزِ نهم لحنِ ابتدایی
میگرفت. PROMPT_VERSION حالا روی هر پیام ذخیره میشود.
هر پاسخ حاشیهنویسی میشود: مدل، نسخهی پرامپت، پایه، collection، مقدارِ
فیلتر، تعداد chunk، توکن، تأخیر و from_cache. فیدبک reason اختیاری گرفت تا
لایک/دیسلایک بعداً قابلِ تحلیل باشد («بشه چک کرد کدوم بهتر بوده»).
ai_feedback_report --by prompt_version نسبتِ رضایت را کنارِ مصرف/هزینه
میگذارد. rag_eval سوالهای واقعی را از مسیرِ واقعی میپرسد، با
--retrieval-only برای اجرای ارزان.
rag/services/lesson_context.py از lessonId تنها پایه و فصل را درمیآورد؛
normalize_grade_title هم اختلافِ «پایه نهم» در دیتابیس با «ریاضی نهم» در
نامِ collection را میبندد — بدونِ آن مسیرِ gradeId بیسروصدا خالی برمیگشت.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
تا پیش از این کلِ محتوای دیتابیس ردیفهای [DEMO] و [SEED] بود — دانشآموزِ
پایه نهم در دمو «جمع تا ۱۰» میدید. سینیور «یک بیسیکِ کامل با دیتای واقعی»
خواسته بود.
seed_curriculum_from_textbook (رایگان، هیچ فراخوانیِ پولی ندارد):
Grade ← پایهی کتاب
Unit ← فصلِ کتاب
Node ← درسِ داخلِ فصل، از فهرستِ خودِ کتاب
Lesson ← نوعِ مسئلهی استخراجشده در بازهی صفحهی همان درس
intro_text مستقیم از generalSolution کتاب میآید.
generate_questions_from_chunks سوالِ چهارگزینهای با گزینههای انحرافیِ
باورپذیر میسازد (هزینهدار، اعتبارسنجیِ سختگیرانه). سوال اینجا جدا شد
چون ساختنِ گزینهی انحرافیِ درست به مدل نیاز دارد.
purge_demo_data ردیفهای [SEED]/[DEMO] را گزارش و — فقط با --apply — پاک
میکند؛ بدونِ آن این ردیفها در دمو داخلِ API دیده میشوند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
نقشهی فیزیکیِ کتابها (افستِ صفحه، بازهی هر فصل، درسهای داخلِ فصل از
فهرستِ خودِ کتاب) در rag/services/textbooks.py ثبت شد تا عنوانِ فصل از
کتاب بیاید نه از حدسِ مدل — رشتهی فصل باید دقیقاً بینِ ingest و retrieval
یکی باشد وگرنه نتیجه بیسروصدا خالی برمیگردد.
rag_ingest_book کتاب را فصلبهفصل ingest میکند و قابلِ ازسرگیری است:
rag.IngestedPage نشانهی «این صفحه پردازش شد» را نگه میدارد حتی وقتی
صفحه chunkی نساخته، وگرنه هر بار اجرای دوباره بابتِ صفحههای بیمسئله
دوباره پول میگرفت. --plan هزینه را رایگان برآورد میکند.
rag_import_collection نقاطِ موجودِ Qdrant را در LessonChunk میریزد —
فقط میخواند، رایگان و idempotent، برای پایههایی که تیمِ دیگر ingest
کرده بود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bugs made self-ingested content useless, both silently:
- _payload wrote `chapter_number` (int) while math retrieval filters on
`chapter` (the full "فصل ۲: کسر" string), so anything we ingest could
never match a search — no error, just permanently invisible content.
- ingest_pdf flattened the vision extraction into one text blob and threw
away type/keywords/generalSolution/problem/solution. LessonChunk is the
source of truth, so rebuilding meant paying for vision again — and the
problem/solution pairs are the input for building lesson questions.
LessonChunk now stores those fields and _payload emits them under the same
keys the other team's collections use, so ours and theirs stay one shape.
Also fixes a regression from the whitenoise commit: the Manifest storage
variant raises when staticfiles.json is absent, which broke all 23 admin
rendering tests (Django forces DEBUG=False in tests, so USE_FINDERS is off)
and would have taken the admin panel down entirely the day DEBUG=0 ships
without a collectstatic run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Admin/DRF/Swagger CSS and JS never load under gunicorn since Django's
staticfiles app only auto-serves through `runserver`. WHITENOISE_USE_FINDERS
is tied to DEBUG so no collectstatic step is needed while DEBUG=1.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Admin login (and any POST form) fails with "Origin checking failed"
behind a proxy that terminates TLS, since Django had no trusted-origins
list configured at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New progress.Character model: an admin-managed catalog (same pattern as
subscription.Plan) of selectable mascots, each unlocked once the user's
XP crosses unlock_xp. No separate ownership table yet — unlocking is a
pure function of UserStats.xp, kept intentionally minimal since this is
the first of an incremental rollout the senior asked for.
- GET /api/characters — catalog with per-user unlocked/selected flags
- POST /api/characters/select — picks an active character, rejecting
ones the user hasn't unlocked yet
- UserStats.selected_character_id tracks the pick
- seed_test_data now seeds 4 sample characters (owl/fox/turtle/lion)
for local/staging testing
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
payment.services.call_payment_gateway was a stub returning a fake
payment.gateway/pay/{id} URL. It's replaced with a real Zarinpal
integration (payment/zarinpal.py, Payment API v4):
- create_order/CreateOrderView still never call Zarinpal directly —
paymentUrl points at our own GET /api/payment/orders/{id}/pay
(PaymentRedirectView), which requests the real Zarinpal authority on
first visit and redirects to StartPay. This keeps order creation from
depending on Zarinpal's uptime/latency, and means none of the existing
purchase/idempotency tests needed to change.
- GET /api/payment/zarinpal/callback (ZarinpalCallbackView) is where
Zarinpal redirects the user back to; it always re-verifies
server-to-server via zarinpal.verify_payment before marking an order
paid, so a forged Status=OK in the query string alone does nothing.
- zarinpal.py fails closed (503 / invalid) when ZARINPAL_MERCHANT_ID
isn't configured, matching payment/bazaar.py's existing convention —
never a fake successful payment.
Adds Order.gateway_authority, PUBLIC_BASE_URL/ZARINPAL_* settings, and
a payment_pay throttle scope.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
next_refill_at was set on UserHearts but never read or recalculated,
so a heart lost to zero never came back. UserHearts.apply_regen() now
catches up missed 24h intervals on read/deduct, and lose_heart() starts
the timer only when it isn't already running.
Any user with an active (non-expired) subscription now skips heart
deduction entirely, regardless of which plan they're on — good enough
for now until per-plan perks are needed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The skill-map response gave units -> nodes with id/title/status but no way
to map a node to its lessons, and no other endpoint fills that gap
(/api/progress/nodes doesn't either). Any client rendering the map needs
this to know which lesson to open when a node is tapped.
The ids are already computed in the cached _grade_tree; this just surfaces
them. Additive and backward-compatible; content tests pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
rag/services/tutor.py
- deleted: answer_question() had no callers (AskAIView calls
retrieve_context + generate_answer directly) and carried an unused
import.
rag/services/config.py
- docstring: drop the claim that the lesson-filter key is settings-driven
(it is auto-selected per subject in known_collections.py).
.env.example
- drop the leftover note about the removed RAG_QDRANT_LESSON_FILTER_KEY.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
subscription/views.py
- PlansListView: served from cache (subscription:plans:v1, 1h TTL);
plans change ~never and the list is read on every subscription screen.
subscription/receivers.py (new)
- post_save/post_delete on Plan -> cache.delete(PLANS_CACHE_KEY).
subscription/apps.py
- SubscriptionConfig.ready() imports receivers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TadrisinoTokenAuthentication ran one SELECT on accounts_user for every
authenticated request. Off by default (AUTH_USER_CACHE_TTL=0) so behaviour
is unchanged until a deployment opts in.
accounts/authentication.py
- _get_request_user(): when AUTH_USER_CACHE_TTL>0, cache all concrete
field values under auth:user:<id> and rebuild a full User instance
(so save(update_fields=[...]) stays safe); otherwise load from DB as
before. invalidate_user_cache() / _load_user_or_fail() helpers.
accounts/receivers.py (new)
- post_save/post_delete on User -> invalidate_user_cache(pk); covers
every in-app write path (SetUserGrade, login, refresh, last_activity).
accounts/apps.py
- AccountsConfig.ready() imports receivers.
accounts/tests.py
- AuthUserRowCacheTests: disabled -> loads every request; enabled ->
loads once then cache; User.save() invalidates; SetUserGradeView still
persists and the next request sees the new grade.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
progress/views.py
- GuestAwareReadThrottle (same guest-by-IP pattern as the other apps);
throttle_scope='read' on UserSummaryView, StreakCalendarView,
LeaderboardView.
notifications/views.py
- GuestAwareReadThrottle + throttle_scope='read' on NotificationListView.
- list capped at NOTIFICATIONS_MAX=100 newest (Meta.ordering already
newest-first); unreadCount still counted over the full set.
notifications/tests.py
- 150 rows -> 100 returned newest-first, unreadCount = 75.
rag/views.py
- ChunkListView: explicit order_by('-id') before the existing [:200] cap.
ai_tutor/views.py
- ConversationMessagesView: return the newest 200 messages in
chronological order instead of the whole conversation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GET /api/home is the most-hit authenticated endpoint (every app open) and
was running one COUNT per node. Now it is a flat number of queries
regardless of grade size, and the shared curriculum skeleton is served
from cache; per-user progress/stats stay live.
content/views.py
- _node_status(): takes a precomputed set of completed lesson_ids and a
node's lesson_ids instead of firing its own COUNT query.
- HomeView.get(): one query for all completed lesson_ids of the user in
the grade, status computed in Python; grade tree (units -> nodes ->
lesson_ids, no per-user data) read from cache
(content:tree:grade:{id}:v{N}); missing grade -> 404 (was an
unhandled 500); grade with no units -> empty units, not 404.
- GradesListView: served from cache (content:grades:v{N}).
- curriculum_version() / bump_curriculum_version(): one shared version
key; bumping it invalidates every content cache key at once.
- both GET views get throttle_scope='read' + GuestAwareContentThrottle.
content/receivers.py
- post_save/post_delete on Grade/Unit/Node/Lesson -> bump_curriculum_version().
content/tests.py
- HomeViewScalabilityTests: query count flat for 2 vs 20 nodes; 2nd call
hits cache (no content_unit/node SQL); a new Node invalidates the tree;
missing grade 404; empty grade 200.
- GradesListCacheTests: list cached, invalidated on new Grade.
- ReadThrottleTests: /api/home returns 429 past the limit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
settings.py
- DATABASES.default.CONN_MAX_AGE from DB_CONN_MAX_AGE env (default 60);
harmless on SQLite, saves a TCP+auth handshake per request on Postgres.
- DEFAULT_RENDERER_CLASSES: BrowsableAPIRenderer only when DEBUG — no
HTML API browser or its overhead in production.
- DEFAULT_THROTTLE_RATES['read'] = READ_THROTTLE_RATE env (default
120/min): a cheap ceiling for the hot GET endpoints.
- AUTH_USER_CACHE_TTL env (default 0 = off): TTL for the per-request
user-row cache added in a later commit; 0 keeps current behaviour.
.env.example
- document DB_CONN_MAX_AGE / AUTH_USER_CACHE_TTL / READ_THROTTLE_RATE.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
README.md
- `rag` app row in the app table.
- new "دستیار هوش مصنوعی (RAG)" subsection: what /api/ai/ask does now,
the OPENAI_/QDRANT_ keys it needs, how to test it in Swagger, the
stream flag, rag_catalog, and the internal ingest endpoints.
- production-notes bullet updated: /api/ai/ask is real RAG when keys are
set, no longer "stub only".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The math-app frontend prototype drives a RobotMascot with a fixed set of
moods; this gives that animation an explicit backend signal. Complements
the existing Lesson.mascot_animation / LessonIntro.mascotAnimation, which
are unchanged.
serializers.py
- AnswerResponseSerializer: new mascotMood field.
views.py
- QuestionAnswerView: mascotMood = 'happy' for a correct answer else
'encourage', on both the fresh response and the idempotent-replay
response.
tests.py
- QuestionAnswerMascotMoodTests: correct -> happy, wrong -> encourage,
and the mood is preserved across an idempotent replay.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When RAG is configured the endpoint retrieves lesson-scoped chunks and
generates a grounded answer; otherwise the previous deterministic stub
runs unchanged, so keyless runs and existing tests are unaffected.
Quota / idempotency / user-row lock / clean-503 behaviour is preserved on
every path.
models.py
- Conversation.subject (CharField) and Conversation.lesson_number
(IntegerField): retrieval metadata sent on the first ask and reused
for the rest of the conversation.
migrations/0004_conversation_lesson_number_conversation_subject.py
- migration for the two new fields.
serializers.py
- AskRequestSerializer: optional subject / grade / lessonNumber to pin
the Qdrant collection and lesson/chapter.
- AskResponseSerializer: usedChunks (retrieved [{title, summary}]) and
retrievalDebug (collection / filterKey / chapter-or-lesson / counts,
emitted only under DEBUG).
views.py
- generate_tutor_answer(): unchanged signature; RAG-configured branch
calls rag.services generation, stub branch otherwise.
- AskAIView.post(): resolve subject/grade/lessonNumber (falling back to
the Conversation), check the answer cache via resolve_target(), run
retrieve_context + generate_answer on a miss and populate the cache,
return usedChunks (+ retrievalDebug under DEBUG). Failures still
release the reserved quota slot and surface as 503.
- AskAIView._streaming_response(): opt-in "stream": true returns a
text/event-stream of chunk / done / error events; same retrieval,
phase-3 persist, idempotency and quota accounting as the JSON path,
and it consults the answer cache first. Non-stream JSON contract is
untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
settings.py
- load .env via python-dotenv (override=False) so local runs and the RAG
keys work without `export`; production/docker env still wins.
- add 'rag' to INSTALLED_APPS.
- new RAG / AI provider block: OPENAI_API_KEY / OPENAI_BASE_URL (GapGPT)
/ OPENAI_CHAT_MODEL / OPENAI_VISION_MODEL / OPENAI_EMBEDDING_MODEL /
OPENAI_TIMEOUT_SECONDS, QDRANT_URL / QDRANT_API_KEY,
RAG_COLLECTION_NAME_TEMPLATE / RAG_GRADE_TITLE_OVERRIDES /
RAG_RETRIEVAL_TOP_K / RAG_FORCE_MOCK / RAG_ANSWER_CACHE_TTL.
- add 'rag_ingest' to DEFAULT_THROTTLE_RATES.
- under `manage.py test`: force RAG_FORCE_MOCK on and RAG_ANSWER_CACHE_TTL
to 0 so no test hits OpenAI/Qdrant or a warm cache.
urls.py
- mount rag.urls under /api/.
requirements.txt
- python-dotenv; openai, qdrant-client; pymupdf / pdf2image / pillow
(optional, only for PDF ingestion).
.env.example
- document the OPENAI_* / QDRANT_* / RAG_* keys, the discovered Qdrant
collection names, and the parked OPENAI_API_KEY_OPENAI_DIRECT slot
(no provider-switch logic by design).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>