Files
MathApp/docs/technical-handbook.md
Zareian 39247f6d53 senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- remove the family app from the root (separate parent accounts, invite codes, parental
  settings); progress.0006 drops its tables, permissions and migration history, and
  Profile.parent_phone, the two family notification types and ?userId= on
  /api/progress/nodes go with it
- GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes,
  questions, accuracy, islands, tutor questions) for the "parent report" page
- profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it
- GET /api/payment/invoices; orders now snapshot list price and plan title
- tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11),
  refusal marker replaced with fixed text; self-harm gets a help line instead
- streamed answers now record token usage (it was always 0)
- daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota
- delete_users management command for wiping test accounts across every app
- docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:38:43 +03:30

94 KiB
Raw Blame History

مستندِ فنیِ تدریسینو

معماری، ساختارِ کد، مدلِ داده، APIها، دستیارِ هوشِ مصنوعی و داده‌ی Qdrant، گیمیفیکیشن، امنیت، استقرار و مسیرِ مقیاس‌پذیری — برای خواننده‌ای که با پروژه آشنایی قبلی ندارد و می‌خواهد آن را توسعه دهد یا نگهداری کند.

تاریخ ۸ مهر ۱۴۰۵ (۳۰ سپتامبر ۲۰۲۶)
نویسنده علیرضا زارعیان — بک‌اند و فرانت‌اند
مخاطب توسعه‌دهنده‌ی تازه‌وارد، مدیرِ فنی، و هر کسی که باید درباره‌ی ادامه‌ی پروژه تصمیم بگیرد
کدِ مبنا بک‌اند 957c9d0 (مخزنِ Tadrisino/MathApp ) · فرانت 0e56d9c (مخزنِ Tadrisino/MathApp_frontend )
نشانی‌ها اپ: math.tadrisino2.ir · API: mathapi.tadrisino2.ir · مستنداتِ زنده‌ی API: /api/docs/

این فایل نسخه‌ی Markdownِ «مستند-فنی-تدریسینو» است. نسخه‌ی PDF با همان محتوا و صفحه‌آراییِ کامل کنارِ همین فایل است: technical-handbook.pdf.

۱. معرفیِ پروژه در یک نگاه

تدریسینو یک اپِ آموزشِ ریاضی به سبکِ دولینگو است: دانش‌آموز روی نقشه‌ای از «جزیره»ها جلو می‌رود، هر جزیره یک آزمونِ کوتاه از مسئله‌های خودِ کتابِ درسی است، و هر جا گیر کرد از دستیارِ هوشِ مصنوعی («پی») می‌پرسد که جوابش را از روی همان کتاب می‌سازد.

شاخص مقدار
سوم، ششم، هفتم، هشتم تا دوازدهم ۸ پایه
سؤالِ چهارگزینه‌ای از مسئله‌های کتاب ۲٬۵۴۵
اندپوینتِ API، مستند در Swagger ۶۱
تستِ خودکار، همه سبز ۶۸۵
اپِ جنگو، هرکدام یک مسئولیت ۱۵
تکه‌ی کتاب در Qdrant (۱۱ collection) ۳٬۵۱۷
همراهِ گیمیفیکیشن، هرکدام ۶ حالتِ چهره ۴
بک‌اند و فرانت؛ یک قراردادِ API ۲ مخزن

کاربرها و کاری که می‌کنند

کاربر چه می‌کند کجای کد
دانش‌آموزِ مهمان بدونِ شماره وارد می‌شود، پایه‌اش را انتخاب می‌کند و بازی می‌کند. اگر بعداً با شماره ثبت‌نام کند، همان حساب می‌ماند و امتیازش گم نمی‌شود. accounts/views.py
دانش‌آموز از هر سنی (بزرگسالی که فقط یک مبحث را می‌خواهد هم پایه‌ی همان مبحث را انتخاب می‌کند). با شماره‌ی موبایل و کدِ پیامکی وارد می‌شود؛ جزیره‌ها را بازی می‌کند، از دستیار می‌پرسد، امتیاز و سکه و زنجیره‌ی روزانه جمع می‌کند، همراه انتخاب می‌کند، اشتراک می‌خرد، تیکتِ پشتیبانی می‌فرستد. content/ progress/ ai_tutor/
والد حسابِ جدا ندارد: «گزارشِ والدین» را در حسابِ خودِ دانش‌آموز می‌بیند — زمانِ تمرین، سوال‌ها و مقایسه با هفته‌ی پیش، با دو نمودار. progress/report.py
ادمین (کارمندِ شرکت) از پنلِ /admin/ سؤال، درس، پلن، همراه و تیکت را بدونِ برنامه‌نویس ویرایش می‌کند؛ سؤال‌ها را فله‌ای وارد و خارج می‌کند. */admin.py

تاریخچه‌ی کوتاه

زمان اتفاق
مرداد ۱۴۰۵ طراحیِ معماری و مدل‌های داده؛ هویتِ کاربر؛ جداسازیِ اپ‌ها. بازبینیِ امنیتیِ اول و بستنِ حفره‌ها، محدودیتِ نرخ در کلِ سیستم.
شهریور دستیار از پاسخِ ساختگی به RAGِ واقعی روی کتاب رسید؛ اتصالِ زرین‌پال؛ گیمیفیکیشن؛ خواندنِ پنج کتابِ ریاضی و ساختِ هزاران سؤالِ تأییدشده؛ حسابانِ ۱۱ و ۱۲.
۲ تا ۷ مهر فرانت به ما سپرده شد و از نو ساخته شد؛ هر دو روی دامنه‌ی تازه زنده شدند؛ پیامکِ واقعی؛ شکل در جوابِ دستیار؛ همراهِ چهارم؛ بازبینیِ نهایی روی سایتِ زنده.

مدلِ سناریو (Use Case)

چه کسانی با سامانه کار می‌کنند و هرکدام چه کاری انجام می‌دهند. خطِ‌چینِ بنفش یعنی آن کار به یک سرویسِ بیرونی وابسته است. «extend» یعنی دانش‌آموز از دلِ آزمون، پس از پاسخِ نادرست، مستقیم از دستیار می‌پرسد «چرا غلط شد؟». جزئیاتِ گام‌به‌گامِ مهم‌ترین سناریوها در بخشِ ۶ آمده است.

مدل سناریو (Use Case)

شکلِ ۱-۱ — مدلِ سناریو: سه نقشِ کاربری (والد با حسابِ خودِ دانش‌آموز)، یازده کارِ اصلی، و سه سرویسِ بیرونی.

واحدهای محتوا (این واژه‌ها در کلِ سند تکرار می‌شوند)

پایه Grade ۸ ← فصل Unit ۵۸ ← جزیره Node ۲۶۳ ← درس Lesson ۲٬۶۱۷ ← سؤال Question ۲٬۵۴۵ ← گزینه Choice

«درس» در این پروژه یعنی یک «تیپ‌مسئله»ی کتاب، نه یک جلسه‌ی کلاس. برای همین درس خیلی ریز است و اپ در سطحِ جزیره (Node) بازی می‌شود: آزمونِ هر جزیره از درس‌هایش سؤال برمی‌دارد.

مدلِ رابطه‌ای (ER)

جدول‌های اصلیِ دیتابیس و ارتباطِ میانِ آن‌ها، دسته‌بندی‌شده بر اساسِ اپ. خطِ ممتد یک کلیدِ خارجیِ واقعی (FK) در داخلِ یک اپ است: دو خطِ موازی سمتِ «دقیقاً یک» و پای کلاغی سمتِ «صفر یا چند» (مثلاً هر پایه چند فصل دارد). فیلدِ بنفش با ⇢ ارجاعِ سبک به جدولِ اپِ دیگر است: فقط شناسه ذخیره می‌شود و قیدِ دیتابیسی ندارد تا هر اپ مستقل بماند (بخشِ ۳). خطِ‌چینِ بنفش دو نمونه از همین ارجاع‌های سبک را نشان می‌دهد. فیلدهای کم‌اهمیت حذف شده‌اند؛ فهرستِ کامل در بخشِ ۷ و در <app>/models.py.

مدل رابطه‌ای (ER)

شکلِ ۱-۲ — مدلِ رابطه‌ای؛ نام‌ها و ارتباط‌ها مستقیم از مدل‌های جنگو استخراج شده‌اند.

۲. انتخاب‌های فناوری و چرایی‌شان

هر انتخاب یک دلیل و یک هزینه دارد. ستونِ آخر هزینه را صادقانه می‌گوید، چون همان چیزی است که نفرِ بعدی باید بداند.

لایه انتخاب چرا چرا نه جایگزین هزینه / ریسک
بک‌اند Django 5.2 + Django REST Framework ORM و migration و پنلِ ادمینِ آماده در یک بسته؛ پنلِ ادمین یعنی محتوا بدونِ برنامه‌نویس ویرایش می‌شود. DRF احرازِ هویت، مجوز، محدودیتِ نرخ و سریال‌سازی را استاندارد می‌دهد. پایتون همان زبانِ کتابخانه‌های هوشِ مصنوعی است ( openai، qdrant-client، pymupdf ). FastAPI سریع‌تر و async است ولی ادمین، ORM و migration را باید جدا سرهم کرد. Node.js زبانِ بک‌اند را از زبانِ لایه‌ی AI جدا می‌کرد. جنگو پیش‌فرض همگام (sync) است؛ جوابِ طولانیِ دستیار یک نخ را نگه می‌دارد. با ۲۴ نخِ هم‌زمان حل شد (بخشِ ۱۴).
قراردادِ API OpenAPI با drf-spectacular اسکیما از خودِ کد ساخته می‌شود، پس هیچ‌وقت از کد عقب نمی‌ماند. Swagger در /api/docs/ قراردادِ مشترکِ دو مخزن است. سندِ دستی یا «در ذهنِ کسی» — که دقیقاً باعثِ ناهمخوانیِ اولیه‌ی فرانت و بک‌اند شد. —
فرانت React 18 + TypeScript (strict) + Vite + Tailwind فرانتِ اولیه را توسعه‌دهنده‌ی قبلی با React ساخته بود؛ در بازسازی نگه داشته شد چون بزرگ‌ترین اکوسیستم را دارد و نیروی جایگزینش راحت پیدا می‌شود. TypeScript ناهمخوانیِ شکلِ داده با API را در زمانِ build می‌گیرد. Vite buildِ سریع و Tailwind رنگ و فاصله‌ی یکدست. وبِ موبایل‌محور یعنی بدونِ نصب و بدونِ فروشگاه روی هر گوشی باز می‌شود. React Native / Flutter نیازِ انتشار در فروشگاه و build جدا داشت؛ نسخه‌ی اندروید (کافه‌بازار) بعداً می‌تواند اضافه شود. بدونِ کتابخانه‌ی router و state؛ کمتر وابستگی، ولی الگوی داخلی را باید خواند (بخشِ ۴).
دیتابیس SQLite (فعلاً) روی سرورِ مشترکِ شرکت صفر نگهداری دارد: یک فایل، بکاپ با یک دستور. برای حجمِ فعلی کافی است. کد از ORM استفاده می‌کند و به SQLite گره نخورده. Postgres روی همان سرور موجود است؛ مهاجرت به هماهنگی با ادمینِ سرور نیاز دارد و به مرحله‌ی بعد موکول شده است. فقط یک نویسنده در لحظه. اولین گلوگاهِ مقیاس است (بخشِ ۱۴).
جست‌وجوی کتاب Qdrant (بانکِ برداری) تیمِ دیگرِ شرکت کتاب‌های سوم/ششم/هفتم را از قبل در یک Qdrantِ مشترک گذاشته بود؛ همان را ادامه دادیم تا داده دوباره ساخته نشود. فیلتر روی فیلدِ «فصل» دارد. pgvector به Postgres نیاز داشت؛ FAISS سرور ندارد و بینِ دو تیم اشتراک‌پذیر نیست. سرویسِ مشترک با تیمِ دیگر؛ پاک‌کردن باید با احتیاط باشد (بخشِ ۹).
مدلِ زبانی GapGPT ← gpt-4.1-mini GapGPT یک واسطِ سازگار با OpenAI است که از داخلِ ایران جواب می‌دهد. مدل با سنجش انتخاب شد، نه با جدولِ قیمت: روی ۱۰۲ مسئله‌ی تصحیح‌شده ۹۵–۹۶ درست در برابرِ ۸۵–۹۰ برای gpt-4o-mini ؛ هر جواب با پرامپتِ امروز حدودِ ۰٫۲ سنت (≈ ۶۰۰ تومان؛ docs/ai-cost-and-guardrails.md). دستیار فقط درباره‌ی ریاضی حرف می‌زند و سقفِ روزانه‌اش برای کاربرِ رایگان ۳ و برای مشترک به اندازه‌ی پلن است. OpenAIِ مستقیم از ایران در دسترس نیست. مدل‌های گران‌تر در دورِ اولِ سنجش (۱۴ مدل) مزیتی نشان ندادند. کُندیِ سرویس (حتی «سلام» ۷ ثانیه) دستِ ما نیست؛ اعتبار دو بار تمام شده.
ورود توکنِ امضاشده‌ی خودمان + کدِ پیامکی کاربرها با شماره وارد می‌شوند، نه نام‌کاربری/رمز؛ مدلِ کاربرِ جنگو به این نمی‌خورد. توکن با django.core.signing امضا می‌شود؛ یک وابستگیِ کمتر از کتابخانه‌ی JWT. JWT همان کار را می‌کند با یک کتابخانه‌ی اضافه. امنیتش به کلیدِ امضا بسته است (بخشِ ۱۲، موردِ ۱).
پیامک / پرداخت کاوه‌نگار · زرین‌پال · کافه‌بازار سرویس‌های ایرانیِ رایج؛ کاوه‌نگار با «ارسال با الگو» که روی خطِ شرکت فعال است؛ زرین‌پال برای وب، بازار برای خریدِ درون‌برنامه‌ایِ اندروید. — پرداختِ واقعی تا انتها هنوز آزموده نشده.
اجرا gunicorn (gthread) پشتِ nginx پشتِ ArvanCloud الگوی استانداردِ اجرای جنگو. آروان CDN و سپرِ حمله‌ی ایرانی است. WhiteNoise فایل‌های استاتیکِ ادمین را از خودِ جنگو سرو می‌کند. — سرور مشترک است؛ مالِ ما فقط یک پوشه و یک سرویس.

اصلِ کلیِ انتخاب‌ها

هر جا ممکن بود، ابزارِ آماده و رایج انتخاب شده است؛ هر جا انتخاب بر کیفیت اثر مستقیم داشت (مانندِ مدلِ هوشِ مصنوعی)، گزینه‌ها پیش از انتخاب با داده‌ی واقعی سنجیده شده‌اند.

۳. معماریِ کلی

دو برنامه‌ی جدا که فقط از راهِ HTTP با هم حرف می‌زنند: فرانت (فایل‌های ثابت در مرورگر) و بک‌اند (API). هر دو روی یک سرورِ مشترکِ شرکت‌اند.

نقشه‌ی استقرار

شکلِ ۳-۱ — نقشه‌ی استقرار. همه‌ی کلیدهای سرویس‌های بیرونی فقط در .env ِ سرورند و هیچ‌وقت به مرورگر نمی‌رسند.

درونِ بک‌اند: «یک برنامه، چند اپِ مستقل» (Modular Monolith)

بک‌اند یک برنامه‌ی جنگوست که به ۱۵ «اپ» تقسیم شده. هر اپ یک پوشه است با مدل‌ها، APIها و تست‌های خودش. این همان الگویی است که اجازه می‌دهد چند نفر هم‌زمان روی بک‌اند کار کنند (بخشِ ۱۴).

اپ مسئولیت مدل‌ها (جدول‌ها)
accounts هویت، ورود با کدِ پیامکی، نشستِ ورود، پروفایل User Profile Session OTPCode
content برنامه‌ی درسی، سؤال، آزمونِ جزیره، ثبتِ جواب و پیشرفت Grade Unit Node Lesson Question Choice UserAnswer UserLessonCompletion UserNodeCompletion NodeProgress QuestionReport
progress امتیاز، سکه، قلب، زنجیره، جدولِ رتبه، همراه‌ها UserStats UserHearts StreakRecord ActiveDay LeaderboardEntry Character
ai_tutor گفتگو با دستیار، سهمیه، بازخوردِ 👍/👎 Conversation Message MessageFeedback AskIdempotency
rag خواندنِ کتاب، Qdrant، ساختِ پرامپت، شکل‌ها LessonChunk LessonChunkImage IngestedPage
subscription پلن‌ها و اشتراکِ کاربر Plan Subscription
payment سفارش، تراکنش، زرین‌پال، بازار Order Transaction BazaarCredential
discount کدِ تخفیف و پیشنهادها DiscountCode Redemption Offer UserOffer
affiliate معرفی به دوستان و پورسانت AffiliateAccount Conversion
notifications اعلان‌های داخلِ اپ Notification
support تیکت و گزارشِ باگ Ticket TicketMessage BugReport
campaigns پیامِ گروهی (ذخیره و هدف‌گیری؛ زمان‌بندیِ خودکار ندارد) MessageCampaign CampaignDelivery
statuslog ثبتِ عمومیِ «وضعیتِ فلان چیز از X به Y رفت» StatusChangeLog
config تنظیمات، مسیرهای اصلی، middleware، سلامت، مدیریتِ خطا —

دو قاعده‌ی معماری که از کد پیدا نیست

۱. بینِ اپ‌ها کلیدِ خارجی (FK) نیست

اپِ progress کاربر را با یک عددِ ساده نگه می‌دارد، نه با اتصالِ دیتابیسی به accounts :

# progress/models.py
class UserStats(models.Model):
    user_id = models.IntegerField(unique=True)  # ارجاعِ سبک
    xp = models.PositiveIntegerField(default=0)
    coins = models.PositiveIntegerField(default=0)

چرا: اگر روزی «پرداخت» یا «دستیار» سرویسِ جدا شد، زنجیره‌ای از وابستگی پاره نمی‌شود. داخلِ یک اپ FK آزاد است (مثلاً Question → Lesson ).

۲. اپ‌ها با «رویداد» خبر می‌دهند

وقتی پرداخت تأیید شد، payment خودش اشتراک و اعلان را نمی‌سازد؛ رویداد می‌فرستد یا تابعِ سرویسِ اپِ دیگر را صدا می‌زند، و هر اپ کارِ خودش را می‌کند. فایل‌های مربوط:

*/signals.py رویدادهایی که یک اپ می‌فرستد · */receivers.py شنونده‌ها · */services.py توابعی که اپِ دیگر می‌تواند صدا بزند (مثلاً notifications.services.notify، affiliate.services.record_paid_order ).

۴. ساختارِ فایل‌ها و نقشِ هر فایل

این بخش نقشه‌ی راه است: هر فایل چه چیزی دارد و به کدام فایل وصل است.

۴-۱ مخزنِ بک‌اند ( Tadrisino/MathApp )

MathApp/
├── config/                 تنظیمات و سیم‌کشیِ کلِ پروژه
│   ├── settings.py         همه‌ی تنظیمات؛ خواندنِ .env؛ نرخِ throttleها؛ کلیدِ امضا؛ مدل‌های AI
│   ├── urls.py             ریشه‌ی مسیرها: /admin، /api/docs، /api/health و include هر اپ
│   ├── middleware.py       لاگِ JSONِ هر درخواست، no-store، سقفِ ورودِ ادمین
│   ├── exceptions.py       هر خطا → پاسخِ JSONِ یکدست، بی‌جزئیاتِ داخلی
│   ├── health.py           GET /api/health: دیتابیس، migration، کش، محتوا، هشدارها
│   └── log_formatters.py   قالبِ JSONِ لاگ
├── accounts/  content/  progress/  ai_tutor/  rag/  subscription/  payment/
├── discount/  affiliate/  notifications/  support/  campaigns/  statuslog/
├── content/seed/            curriculum.json و questions.json — کلِ محتوا به‌صورتِ فایل در git
├── data/textbooks/          PDFِ کتاب‌ها (در git نیست؛ فقط README)
├── templates/tadrisino/     پوسته‌ی راست‌به‌چپِ Swagger و ReDoc
├── tools/fake_openai.py     جایگزینِ GapGPT برای تستِ بی‌هزینه
├── docs/                    operations، architecture، security-before-production، content-authoring، …
├── deploy.sh                کلِ انتشار در یک اسکریپت (بخشِ ۱۳)
├── gunicorn.conf.py         ۸ نخ در هر worker، timeoutِ ۱۲۰
├── requirements.txt         Django 5.2، DRF، drf-spectacular، openai، qdrant-client، pymupdf، …
├── docker-compose.yml       فقط محلی: Loki + Alloy + Grafana برای دیدنِ لاگ
└── manage.py

۴-۲ آناتومیِ یک اپ (همه‌ی اپ‌ها همین شکل‌اند)

مثال: اپِ content. ترتیبِ جدول همان ترتیبی است که یک درخواست از آن رد می‌شود.

فایل چه چیزی در آن است نمونه‌ی واقعی
urls.py نگاشتِ آدرس به کلاسِ ویو questions/<int:question_id>/answer → QuestionAnswerView
views.py منطقِ هر اندپوینت: چه کسی مجاز است ( permission_classes )، چند بار ( throttle_scope )، و کار اصلی QuestionAnswerView، NodeQuizView، NodeQuizCompleteView
serializers.py شکلِ ورودی و خروجی؛ اعتبارسنجی؛ همین فایل Swagger را می‌سازد AnswerRequestSerializer (فقط selectedOptionId )
services.py منطقِ مشترکی که چند ویو یا اپِ دیگر لازم دارند درصدِ پیشرفت، جزیره‌ی بعدی
models.py جدول‌های دیتابیس و قیدهای یکتایی UserAnswer با قیدِ یکتا روی کلیدِ idempotency
migrations/ تاریخچه‌ی تغییرِ جدول‌ها؛ migrate روی سرور اعمالشان می‌کند 0008 : idِ هر پایه = شماره‌ی پایه
admin.py صفحه‌های پنلِ ادمین، اکشن‌ها، واردکردنِ فله‌ای «وارد کردنِ سؤال‌ها از فایل»
receivers.py واکنش به رویدادِ اپ‌های دیگر باطل کردنِ کشِ نقشه وقتی سؤال عوض شد
management/commands/ دستورهای خطِ فرمان ( python manage.py … ) seed_content، generate_questions_from_chunks
tests*.py تست‌ها؛ هر باگِ پیداشده یک تست دارد tests_frontend_contract.py — نگهبانِ قرارداد با فرانت

۴-۳ مخزنِ فرانت ( Tadrisino/MathApp_frontend )

src/
├── main.tsx              نقطه‌ی شروع؛ App را سوار می‌کند
├── config.ts             نشانیِ API: dist/config.js ← VITE_API_BASE_URL ← /api
├── api/                  تنها درِ ارتباط با سرور — هیچ جای دیگر fetch نمی‌زند
│   ├── http.ts           توکن، timeout، تکرارِ GET، refreshِ تک‌نسخه‌ای، SSE، Idempotency-Key
│   ├── index.ts          یک تابع برای هر اندپوینت: api.nodeQuiz، api.lesson.answer، …
│   └── types.ts          شکلِ پاسخ‌ها، از روی پاسخِ واقعیِ سرور
├── app/                  App.tsx: جریانِ ورود · MainApp.tsx: تب‌ها و صفحه‌ها
├── nav/                  ناوبریِ پشته‌ای خودمان، هماهنگ با دکمه‌ی back گوشی
├── state/                داده‌ی مشترک (پروفایل، نقشه) و گفتگوی دستیار
├── screens/              یک فایل برای هر صفحه: HomeScreen، QuizScreen، AskScreen، ReportScreen، …
├── components/           اجزای مشترک: مسیرِ جزیره‌ها، برگه‌ی پایین، نوارِ پایین
├── mascots/              سیگما، دلتا، پی، تدی — SVG از کد، ۶ حالتِ چهره
├── diagram/              JSONِ شکلِ دستیار → SVG؛ مختصات، ریشه، زاویه این‌جا حساب می‌شود
└── lib/                  رقمِ فارسی، تاریخِ شمسی، MathText، پیامِ خطای دوستانه، صدا

قاعده‌ی فرانت: صفحه‌ها هرگز مستقیم سرور را صدا نمی‌زنند؛ همیشه screens/* → api/index.ts → api/http.ts.

۵. سفرِ یک درخواست، فایل‌به‌فایل

نمونه: ثبتِ پاسخِ یک دانش‌آموز به یک سؤال در آزمونِ جزیره. در ادامه مسیرِ کاملِ این درخواست از رابطِ کاربری تا دیتابیس و بازگشتِ پاسخ آمده است. سایرِ درخواست‌ها نیز همین مسیر را طی می‌کنند و تنها ویوِ پاسخ‌دهنده تفاوت دارد.

1. فرانت · صفحه — src/screens/QuizScreen.tsx — کاربر گزینه‌ای را انتخاب می‌کند و صفحه تابعِ API مربوط را فراخوانی می‌کند. صفحه نمی‌داند نشانیِ سرور چیست.

const r = await api.lesson.answer(question.id, optionId, answerKey);

2. فرانت · api — src/api/index.ts — نامِ تابع را به نشانیِ API ترجمه می‌کند. برای هر جواب یک کلیدِ یکتا (Idempotency-Key) ساخته می‌شود تا اگر اینترنت قطع شد و دوباره فرستاده شد، دوبار حساب نشود.

answer: (questionId, selectedOptionId, idempotencyKey = newIdempotencyKey()) =>
  http.create(`/questions/${questionId}/answer`, { selectedOptionId }, { idempotencyKey })

3. فرانت · http — src/api/http.ts — توکن را از localStorage می‌خواند، هدرها را تنظیم می‌کند و درخواست را با محدودیتِ زمانی (timeout) می‌فرستد. اگر ۴۰۱ برگشت، یک بار توکن را تازه می‌کند و دوباره می‌فرستد.

POST https://mathapi.tadrisino2.ir/api/questions/1834/answer
Authorization: Bearer 10:1tQx9k:Xy3…      ← user_id:زمان:امضا
Idempotency-Key: 7f3c…                   {"selectedOptionId": 7290}

4. شبکه — اینترنت ← ArvanCloud ← nginx روی سرور ← سوکتِ یونیکس ← یک نخِ آزاد از gunicorn ( gunicorn.conf.py ).

5. بک‌اند · middleware — config/settings.py → MIDDLEWARE — لایه‌های عمومی به‌ترتیب: امنیت، CORS (آیا درخواست از math.tadrisino2.ir آمده؟)، فایل‌های استاتیک، سقفِ ورودِ ادمین، …، APINoStoreMiddleware (پاسخ در کشِ آروان نماند)، و در آخر APIRequestLoggingMiddleware که در پایان یک خطِ JSON لاگ می‌نویسد ( config/middleware.py ).

6. بک‌اند · مسیر — config/urls.py همه‌ی /api/ را به اپ‌ها می‌سپارد؛ content/urls.py این آدرس را پیدا می‌کند:

path('api/', include('content.urls')),                          # config/urls.py
path('questions/<int:question_id>/answer', QuestionAnswerView.as_view()),  # content/urls.py

7. بک‌اند · هویت — accounts/authentication.py — امضا و انقضای توکن را بی‌رجوع به دیتابیس می‌سنجد و request.user را می‌سازد. بی‌هدر یعنی «مهمانِ ناشناس»، نه خطا؛ ویو تصمیم می‌گیرد.

value = TimestampSigner(salt='tadrisino-access-token').unsign(token, max_age=3600)
if user.tokens_valid_after and issued_at(token) < user.tokens_valid_after: raise AuthenticationFailed

8. بک‌اند · مجوز و سقف — content/views.py → QuestionAnswerView — فقط کاربرِ واردشده، حداکثر ۶۰ جواب در دقیقه (نرخ در DEFAULT_THROTTLE_RATES ِ settings).

permission_classes = [IsAuthenticated]
throttle_scope = 'question_answer'          # 'question_answer': '60/min'

9. بک‌اند · اعتبارسنجی — content/serializers.py — ورودی باید دقیقاً یک عدد باشد؛ هر چیز دیگر ۴۰۰ می‌گیرد. userId هرگز از کلاینت خوانده نمی‌شود، فقط از توکن.

class AnswerRequestSerializer(serializers.Serializer):
    selectedOptionId = serializers.IntegerField()

10. بک‌اند · دیتابیس — ثبتِ جواب و کم کردنِ قلب «همه یا هیچ» در یک تراکنش ( content/models.py، progress/models.py ):

with transaction.atomic():
    UserAnswer.objects.create(user_id=user_id, question=question, choice=choice,
                              correct=choice.is_correct, idempotency_key=idempotency_key)
    if not choice.is_correct:
        hearts = UserHearts.objects.select_for_update().get(user_id=user_id)
        hearts.apply_regen(); hearts.lose_heart(); hearts.save()

قیدِ یکتای (user_id, question, idempotency_key) در دیتابیس آخرین خطِ دفاع است: درخواستِ تکراری نمی‌تواند ردیفِ دوم بسازد. بعد روزِ فعالِ زنجیره ثبت می‌شود ( progress/services.py ).

11. بک‌اند · پاسخ

{"correct": false, "correctOptionId": "7288", "xpEarned": 0,
 "heartsRemaining": 4, "mascotMood": "encourage"}

اگر هر جا خطا شد، config/exceptions.py آن را به JSONِ یکدست تبدیل می‌کند و traceback فقط در لاگ می‌ماند.

12. فرانت · نمایش — QuizScreen.tsx گزینه‌ی نادرست را قرمز و گزینه‌ی درست را سبز نمایش می‌دهد، قلب را کم نشان می‌دهد، چهره‌ی همراه را عوض می‌کند و دکمه‌ی «از پی بپرس چرا غلط شد» را نشان می‌دهد. پیامِ خطا همیشه از lib/errors.ts → friendlyError می‌آید، نه متنِ خامِ سرور.

خلاصه‌ی مسیرِ درخواست

صفحه تابعِ API را صدا می‌زند؛ http.ts توکن را می‌گذارد و می‌فرستد. از آروان و nginx به gunicorn می‌رسد. جنگو از middlewareها رد می‌کند، urls.py ویو را پیدا می‌کند، توکن در authentication.py سنجیده می‌شود، ویو مجوز و سقف را چک می‌کند، serializer ورودی را، و منطق در یک تراکنشِ اتمیک در دیتابیس می‌نویسد. پاسخ JSON است و برای هر درخواست یک خطِ لاگِ JSON ثبت می‌شود.

۶. سناریوهای اصلی

هر نمودار از راست به چپ خوانده می‌شود: ستون‌ها بازیگرها هستند، پیکان‌ها درخواست‌ها. پیکانِ خط‌چین یعنی پاسخ. شماره‌ها ترتیبِ زمانی‌اند.

۶-۱ ورود: مهمان، بعد ثبت‌نام با شماره

سناریوی ورود

کد: accounts/views.py · accounts/phone.py (نرمال‌سازیِ شماره) · accounts/services.py (ارسالِ پیامک) · accounts/authentication.py (توکن) · تست‌ها: accounts/tests_otp_limits.py

۶-۲ بازیِ یک جزیره

سناریوی بازی جزیره

کد: content/views.py ( HomeView، NodeQuizView، QuestionAnswerView، NodeQuizCompleteView ) · فرانت: screens/HomeScreen.tsx، screens/QuizScreen.tsx

۶-۳ «از پی بپرس» — دستیار با کتاب

سناریوی پرسش از دستیار

کد: ai_tutor/views.py → AskAIView · rag/services/lesson_context.py · rag/services/retrieval.py · rag/services/qdrant_store.py · rag/services/generation.py · rag/services/diagram.py · فرانت: screens/AskScreen.tsx، diagram/

سه نکته‌ی طراحی: (۱) تماس با GapGPT بیرون از تراکنشِ دیتابیس است، وگرنه ۳۰ ثانیه انتظار دیتابیس را قفل می‌کرد؛ (۲) اگر GapGPT خطا داد، سهمیه‌ی رزروشده پس داده می‌شود و کاربر ۵۰۳ می‌گیرد، نه ۵۰۰؛ (۳) اولین پیامِ هر گفتگو کش می‌شود — سؤالِ تکراری از کش ۰٫۰۱ ثانیه‌ای برمی‌گردد در برابرِ ~۱۱ ثانیه.

۶-۴ خریدِ اشتراک با زرین‌پال

سناریوی خرید اشتراک

کد: subscription/views.py · payment/services.py · payment/views.py ( PaymentRedirectView، ZarinpalCallbackView ) · payment/zarinpal.py · تست‌ها: payment/tests_audit_fixes.py

چرا این‌قدر پیچیده؟

  • ساختِ سفارش به زرین‌پال وابسته نیست: اگر زرین‌پال قطع باشد، سفارش ساخته می‌شود و فقط رفتن به درگاه عقب می‌افتد.
  • برگشتِ کاربر هیچ چیزی را ثابت نمی‌کند: هر کس می‌تواند آدرسِ برگشت را با Status=OK بسازد. فقط تأییدِ سرور-به-سرور سفارش را «پرداخت‌شده» می‌کند.
  • پرداختِ جاافتاده بازسنجی می‌شود: اگر زرین‌پال موقعِ برگشت جواب نداد، هر بار که اپ وضعیتِ اشتراک را می‌خواند سفارش دوباره سنجیده می‌شود (و دستورِ payment_reconcile ).
  • پلن روی خودِ سفارش است ؛ کدِ تخفیفِ ۱۰۰٪ بی‌درگاه تمام می‌کند؛ تمدید از تاریخِ انقضای فعلی حساب می‌شود.

آزموده‌نشده زرین‌پال درخواستِ پرداخت را با کدِ ۱۰۰ می‌پذیرد، ولی یک پرداختِ واقعی تا انتها هنوز انجام نشده.

۶-۵ گزارشِ والدین

والد حسابِ جدا و کدِ دعوت ندارد (تا مهرِ ۱۴۰۵ داشت؛ به تصمیمِ پس از ارائه حذف شد). صفحه‌ی «گزارشِ والدین» در پروفایلِ خودِ دانش‌آموز است.

# درخواست چه می‌شود
۱ GET /api/user/weekly-report برای هر یک از ۷ روزِ اخیر و ۷ روزِ قبلش: زمانِ تمرین، سوالِ حل‌شده و درست، امتیاز، درس و جزیره‌ی تمام‌شده، سوال از دستیار. فقط دیتای خودِ صاحبِ توکن.
۲ — فرانت جمله‌ی مقایسه («آفرین! این هفته ۲۰ دقیقه بیشتر…») و دو نمودار می‌سازد: زمانِ هر روز در برابرِ همان روزِ هفته‌ی پیش، و سوال‌های درست/نادرستِ هر روز.
۳ GET /api/progress/nodes?userId= فقط شناسه‌ی خودِ کاربر پذیرفته است؛ هر شناسه‌ی دیگری ۴۰۳.

تخمینی: «زمانِ تمرین» اندازه‌گیری نیست: از فاصله‌ی جواب‌ها و پرسش‌ها حساب می‌شود (فاصله‌ی بیش از ۱۰ دقیقه وقفه است). خواندنِ درس بدونِ جواب دادن در آن نیست، پس عدد معمولاً از زمانِ واقعی کمتر است.

۷. مدلِ داده

جدول‌های اصلی، دسته‌بندی‌شده بر اساسِ اپ. → یعنی کلیدِ خارجیِ واقعی (داخلِ همان اپ)، و user_id یعنی ارجاعِ سبک به اپِ دیگر.

accounts هویت

  • User phone, name, is_guest, device_id, grade_id, tokens_valid_after
  • Profile → User · birth_year, parent_phone
  • Session → User · refresh_token, expires_at, revoked
  • OTPCode phone, code, expires_at, failed_attempts

content محتوا

  • Grade id = شماره‌ی پایه (۸ = هشتم)
  • Unit → Grade · title, blurb, order
  • Node → Unit · order (جزیره)
  • Lesson → Node · kind, example, steps
  • Question → Lesson · prompt, type
  • Choice → Question · value, is_correct

content فعالیتِ کاربر

  • UserAnswer user_id, → Question, → Choice
  • UserLessonCompletion user_id, → Lesson
  • UserNodeCompletion user_id, → Node, stars, xp_earned, coins_earned
  • NodeProgress user_id, → Node, status
  • QuestionReport «این سؤال ایراد دارد»

progress بازی

  • UserStats user_id, xp, coins, selected_character_id
  • UserHearts current_hearts, max_hearts=5, next_refill_at, full_refill_at
  • StreakRecord current_streak, longest_streak
  • ActiveDay → StreakRecord, date
  • LeaderboardEntry period, xp_in_period, rank
  • Character slug, name, unlock_xp

ai_tutor · rag دستیار

  • Conversation user_id, lesson_id, grade_id
  • Message → Conversation · role, text, diagram, model_name, prompt_version, latency_ms
  • MessageFeedback → Message · is_helpful
  • LessonChunk problem, solution, solution_verdict, qdrant_point_id
  • IngestedPage collection, page

subscription · payment پول

  • Plan price, duration_days, features, badge
  • Subscription user_id, → Plan, status, expires_at
  • Order user_id, plan_id, amount, status, gateway_authority
  • Transaction → Order · gateway_ref_id, status, paid_at
  • BazaarCredential توکنِ اتصال به بازار

discount · affiliate بازاریابی

  • DiscountCode code, type, value, max_uses
  • Redemption یکتا: هر کاربر × هر کد
  • Offer / UserOffer پیشنهادِ خودکار
  • AffiliateAccount user_id, referral_code, commission_rate
  • Conversion → AffiliateAccount · order_id, commission_amount

support · notifications ارتباط

  • Ticket user_id, subject, status
  • TicketMessage → Ticket · sender, text
  • BugReport
  • Notification user_id, title, body, is_read

campaigns · statuslog

  • MessageCampaign, CampaignDelivery یکتا: کمپین × کاربر × کانال
  • StatusChangeLog label, object_id, old_status → new_status

فهرستِ فیلدها خلاصه است؛ منبعِ کامل <app>/models.py است. نام‌ها و ارتباط‌ها از کد خوانده شده‌اند.

قیدهایی که در خودِ دیتابیس گذاشته شده‌اند (نه فقط در کد)

قید از چه جلوگیری می‌کند
یکتا: UserAnswer(user_id, question, idempotency_key) ثبتِ دوباره‌ی یک جواب با تکرارِ درخواست
یکتا: Redemption(کاربر، کد) استفاده‌ی دوباره از یک کدِ تخفیف
یکتا: CampaignDelivery(کمپین، کاربر، کانال) پیامِ تکراری وقتی کمپین دو بار هم‌زمان اجرا شود
یکتا: LessonChunk(collection, qdrant_point_id) و IngestedPage(collection, page) تکه‌ی تکراری در Qdrant وقتی خواندنِ کتاب از سر گرفته می‌شود

کد و محتوا دو مسیرِ جدا دارند. db.sqlite3 در git نیست. محتوا (برنامه‌ی درسی و سؤال‌ها) به‌صورتِ JSON در content/seed/ است و deploy.sh در هر انتشار آن را وارد می‌کند. داده‌ی کاربرها فقط در فایلِ دیتابیسِ سرور است.

۸. API: قواعد و فهرستِ کامل

مستندِ زنده و قابلِ امتحان: https://mathapi.tadrisino2.ir/api/docs/ (Swagger). جدول‌های زیر از همان اسکیما استخراج شده‌اند.

قواعدِ مشترکِ همه‌ی اندپوینت‌ها

موضوع قاعده
نشانیِ پایه https://mathapi.tadrisino2.ir/api/… — دامنه در کد نیامده و از تنظیمات ( PUBLIC_BASE_URL ) خوانده می‌شود.
ورود هدرِ Authorization: Bearer <token>. توکن یک ساعت اعتبار دارد و با POST /api/auth/refresh تازه می‌شود.
قالبِ داده JSON؛ نامِ فیلدها camelCase ( selectedOptionId )، شناسه‌ها در پاسخ رشته‌اند.
خطا همیشه JSON با detail (و گاهی code مثلِ no_hearts ): ۴۰۰ ورودیِ بد، ۴۰۱ توکنِ نامعتبر، ۴۰۳ اجازه نداری، ۴۰۴، ۴۲۹ زیاد درخواست دادی، ۵۰۳ سرویسِ بیرونی در دسترس نیست.
تکرارِ امن هدرِ اختیاریِ Idempotency-Key روی خرید، دستیار، جواب، پایانِ درس و جزیره: درخواستِ تکراری با همان کلید، همان نتیجه‌ی قبلی را برمی‌گرداند.
سقفِ نرخ هر اندپوینتِ حساس یک throttle_scope دارد؛ نرخ‌ها همه یک‌جا در config/settings.py → DEFAULT_THROTTLE_RATES.
استریم POST /api/ai/ask با "stream": true پاسخ را SSE می‌فرستد: رویدادهای chunk، done، error.

فهرستِ ۶۱ اندپوینت

عمومی بی‌ورود · ورود توکن لازم · داخلی با هدرِ کلیدِ داخلی (سرور-به-سرور)

روش مسیر دسترسی کار
هویت و پروفایل — accounts
POST /api/auth/session عمومی ساختِ کاربرِ مهمان با deviceId
POST /api/auth/otp/request عمومی فرستادنِ کدِ ورود
POST /api/auth/login عمومی ورود با شماره و کد
POST /api/auth/refresh عمومی توکنِ تازه با refresh token
POST /api/auth/logout ورود خروج؛ توکن‌های قبلی باطل
GET · PATCH /api/user/profile ورود خواندن و ویرایشِ پروفایل
POST /api/user/grade ورود ثبتِ پایه
محتوا و آزمون — content
GET /api/grades عمومی فهرستِ پایه‌ها
GET /api/home ورود نقشه‌ی جزیره‌ها + آمار، در یک درخواست
GET /api/nodes/{id}/lessons عمومی درس‌های یک جزیره
GET /api/nodes/{id}/quiz عمومی آزمونِ ۴ سؤالیِ جزیره
POST /api/nodes/{id}/quiz/complete ورود پایانِ جزیره؛ ستاره، امتیاز، سکه، جزیره‌ی بعد
GET /api/lessons/{id}/intro عمومی مثال و قدم‌های درس
GET /api/lessons/{id}/questions عمومی سؤال‌های یک درس
POST /api/lessons/{id}/complete ورود پایانِ درس
POST /api/questions/{id}/answer ورود ثبتِ جواب
POST /api/questions/{id}/report ورود گزارشِ ایرادِ سؤال
GET /api/progress/nodes ورود وضعیتِ کاربر (یا فرزند) روی جزیره‌ها
پیشرفت و بازی — progress
GET /api/user/summary ورود پروفایل + آمار + زنجیره در یک درخواست
GET /api/user/hearts ورود قلب‌ها و زمانِ پر شدن
GET /api/user/streak/calendar ورود تقویمِ روزهای فعال
GET /api/leaderboard عمومی جدولِ رتبه: هفتگی یا همه‌ی زمان‌ها
GET /api/characters ورود همراه‌ها و باز/قفل بودنشان
POST /api/characters/select ورود انتخابِ همراه (فقط بازشده)
دستیار — ai_tutor و rag
POST /api/ai/ask ورود پرسش؛ JSON یا استریم
GET /api/ai/conversations/{id}/messages ورود تاریخچه‌ی گفتگو
POST /api/ai/messages/{id}/feedback ورود 👍/👎 روی یک جواب
POST /api/rag/ingest/text · /pdf داخلی افزودنِ متن یا PDF به Qdrant
GET /api/rag/chunks داخلی مرورِ تکه‌های ذخیره‌شده
اشتراک، پرداخت، تخفیف
GET /api/subscription/plans عمومی پلن‌ها
POST /api/subscription/purchase ورود شروعِ خرید؛ سفارش و لینکِ پرداخت
GET /api/subscription/status ورود وضعیتِ اشتراک (و بازسنجیِ پرداختِ جاافتاده)
POST /api/payment/orders ورود ساختِ سفارش
GET /api/payment/orders/{id} ورود وضعیتِ سفارش
GET /api/payment/invoices ورود پرداخت‌ها و فاکتورهای کاربر (شماره‌ی فاکتور، تخفیف، کدِ پیگیری)
GET /api/payment/orders/{id}/pay عمومی رفتن به درگاهِ زرین‌پال
GET /api/payment/zarinpal/callback عمومی برگشت از درگاه + تأییدِ سرور-به-سرور
POST /api/payment/bazaar/verify ورود تأییدِ خریدِ کافه‌بازار
GET /api/discount/validate ورود سنجشِ کدِ تخفیف
POST /api/discount/redeem ورود ثبتِ مصرفِ کد
GET · POST /api/discount/offers/mine · /redeem ورود پیشنهادهای کاربر
اجتماعی و پشتیبانی
GET · POST /api/affiliate/me · /track · /conversions ورود کدِ معرف، ثبتِ معرفی، پورسانت‌ها
GET /api/notifications ورود اعلان‌ها
PATCH /api/notifications/{id}/read · /read-all ورود خوانده‌شده
GET · POST /api/support/tickets ورود تیکت‌های من / تیکتِ تازه
GET · POST /api/support/tickets/{id} · /messages ورود جزئیات و پیامِ تازه
POST /api/support/bug-reports ورود گزارشِ باگ
GET /api/user/weekly-report ورود «گزارشِ والدین»: ۷ روزِ اخیر در کنارِ ۷ روزِ قبلش
سیستم
GET /api/health عمومی سلامتِ سرویس (بخشِ ۱۳)

۹. دستیارِ هوشِ مصنوعی و داده‌ی Qdrant

دستیار «از حافظه‌ی مدل» جواب نمی‌دهد؛ اول تکه‌های مرتبطِ همان کتاب و همان فصل را پیدا می‌کند و مدل جواب را بر پایه‌ی آن‌ها می‌نویسد. به این روش RAG (Retrieval-Augmented Generation) می‌گویند.

۹-۱ Qdrant چیست و چه چیزی در آن است

Qdrant یک «بانکِ برداری» است: هر تکه‌ی کتاب به یک بردار (۱۵۳۶ عدد) تبدیل می‌شود که «معنا»ی آن را نشان می‌دهد؛ سؤالِ دانش‌آموز هم به بردار تبدیل می‌شود و نزدیک‌ترین تکه‌ها پیدا می‌شوند. هر collection یک کتاب است و هر نقطه (point) یک تکه — معمولاً یک تیپ‌مسئله از یک صفحه، با روشِ حل، صورتِ مسئله و راه‌حلِ کتاب.

collection (کتاب) تکه صفحه‌ی خوانده‌شده چه کسی ساخت
ریاضی هشتم ۵۱۸ ۱۵۰ ما — خواندنِ PDF با مدلِ بینایی
ریاضی نهم ۴۵۰ ۱۴۳ ما
ریاضی دهم ۴۵۸ ۱۷۰ ما
حسابان یازدهم ۳۷۶ ۱۵۱ ما
حسابان دوازدهم ۳۲۶ ۱۴۶ ما
ریاضی سوم · ششم · هفتم ۴۳۶ · ۴۳۷ · ۳۷۹ — تیمِ دیگرِ شرکت؛ با rag_import_collection خوانده شد
علوم تجربی سوم · مطالعات اجتماعی ششم و چهارم ۷۰ · ۴۰ · ۲۷ — تیمِ دیگر (در اپِ ریاضی استفاده نمی‌شود)
جمع ۳٬۵۱۷ ۷۶۰ نشانی: qdrant.tadrisino2.ir — سرویسِ مشترک با تیمِ دیگر

مشخصاتِ فنی

مدلِ بردار (embedding) text-embedding-3-small
ابعاد و فاصله ۱۵۳۶ · کسینوسی
تعدادِ تکه در هر جست‌وجو ۴ ( RAG_RETRIEVAL_TOP_K )
فیلترِ ریاضی فیلدِ chapter = «فصل ۲: عددهای اول»
نامِ collection "{درس} {پایه}" مثلِ «ریاضی نهم»

شکلِ یک نقطه (payload)

{ "chapter": "فصل ۸: حجم و مساحت",
  "grade": "نهم",  "type": "تعریف کره",
  "keywords": ["کره","شعاع","مرکز"],
  "generalSolution": "۱. تعریفِ دایره را…",
  "problem": "…",  "solution": "…",
  "solution_verdict": "agrees",
  "source_page": 139, "chapter_number": 8 }

ساخته در rag/services/ingest.py → _payload

۹-۲ داده چطور وارد شد: از PDF تا Qdrant

  1. PDFِ کتاب data/textbooks/ + نقشه‌ی فصل‌ها در textbooks.py
  2. تصویرِ صفحه ۲۰۰ DPI pdf2image
  3. مدلِ بینایی gpt-4.1-mini ~۰٫۲۲ سنت برای هر صفحه
  4. JSONِ ساختاریافته نوع، کلیدواژه، روش، مسئله، راه‌حل
  5. LessonChunk ردیف در دیتابیس (منبعِ اصلی، با بردار)
  6. Qdrant upsert نقطه + payload IngestedPage برای ازسرگیری

قاعده‌ی مهمِ پرامپت ( rag/services/pdf.py ): اگر مسئله به «شکلِ زیر» ارجاع می‌دهد، مدل باید اندازه‌ها و برچسب‌های شکل را داخلِ متن بنویسد. این قاعده سهمِ مسئله‌های بی‌استفاده‌ی وابسته به شکل را از ~۴۲٪ به ~۱۴٪ رساند. هزینه‌ی خواندنِ هر پنج کتاب در یک روز حدودِ ۳٫۴۴ دلار بود.

۹-۳ کنترلِ کیفیت: راستی‌آزماییِ راه‌حل‌های کتاب

مدلِ بینایی گاهی عدد را بد می‌خواند یا راه‌حل را به مسئله‌ی کناری می‌چسباند. دستورِ rag_verify_solutions هر مسئله را مستقل دوباره حل می‌کند و رأیِ سه‌حالته می‌دهد:

رأی تعداد اثر
agrees راه‌حلِ کتاب درست است ۲٬۴۶۶ فقط از این‌ها سؤال ساخته می‌شود
disagrees راه‌حلِ کتاب غلط است ۳۵۴ راه‌حلش به دستیار داده نمی‌شود؛ فقط روش و صورتِ مسئله
unjudgeable بی‌شکل قابلِ قضاوت نیست ۵۰۸ کنار می‌ماند
بررسی‌نشده ۱۸۹ علوم و مطالعاتِ تیمِ دیگر

متنِ اصلیِ کتاب هیچ‌وقت بازنویسی نمی‌شود؛ جوابِ مستقل جدا در checked_answer ذخیره می‌شود تا آدم بتواند داوری کند.

۹-۴ افزودن و پاک کردنِ داده

کار روش هزینه
افزودنِ کتابِ تازه ۱) PDF را در data/textbooks/ بگذارید و فصل‌ها و page_offset را در rag/services/textbooks.py ثبت کنید.
۲) python manage.py rag_ingest_book --grade نهم --plan ← فقط نشان می‌دهد چه چیزی و با چه هزینه‌ای خوانده می‌شود.
۳) همان دستور بدونِ --plan (قابلِ قطع و ازسرگیری؛ صفحه‌ی خوانده‌شده دوباره خریده نمی‌شود).
۴) rag_verify_solutions ← ۵) seed_curriculum_from_textbook ← ۶) ساختِ سؤال (بخشِ ۱۰).
~۰٫۲۲ سنت/صفحه
افزودنِ یک متن یا PDF POST /api/rag/ingest/text یا /pdf با هدرِ X-Internal-Key. کم
آوردنِ collectionِ تیمِ دیگر rag_import_collection --collection "ریاضی ششم" — فقط می‌خواند، چیزی در Qdrant نمی‌نویسد. رایگان
بازخوانیِ صفحه‌های معیوب rag_ingest_book --grade نهم --refresh figures یا --refresh-pages 12,15,20-24. هر صفحه اول پاک می‌شود (اول Qdrant، بعد دیتابیس — تابعِ clear_page ) و دوباره خوانده می‌شود. مثلِ خواندن
پاک کردن دستورِ جداگانه‌ی «فقط پاک کن» نداریم. برای یک صفحه تابعِ rag.services.ingest.clear_page(collection, page) از manage.py shell. برای کلِ collection، delete_collection ِ کلاینتِ Qdrant — فقط با هماهنگیِ تیمِ دیگر، چون Qdrant مشترک است. —

دو احتیاط (از خواندنِ کد؛ آزموده نشده)

  • پاک کردنِ یک LessonChunk از پنلِ ادمین نقطه‌ی Qdrantِ آن را پاک نمی‌کند (هیچ شنونده‌ای برایش نیست)؛ نقطه بی‌صاحب می‌ماند و در جست‌وجو پیدا می‌شود. از clear_page استفاده کنید.
  • ردیف‌های LessonChunk (نسخه‌ی ساختاریافته با بردار) در دیتابیسِ محلیِ توسعه‌اند، نه روی سرور؛ سرور فقط از Qdrant می‌خواند. از آن فایل بکاپ جدا نگه دارید — با آن، Qdrant بی‌هزینه‌ی embedding دوباره ساخته می‌شود.

۹-۵ از سؤال تا جواب (خلاصه‌ی سناریوی ۶-۳)

مرحله فایل نکته
کدام کتاب؟ rag/services/lesson_context.py از lessonId زنجیره‌ی درس → جزیره → فصل → پایه؛ یا پایه‌ی خودِ کاربر
جست‌وجو retrieval.py · qdrant_store.py · embedding.py متنِ جست‌وجو = سؤال + سؤالِ قبلیِ کاربر (برای «نفهمیدم»)
پرامپت generation.py نسخه‌ی tutor-v10-shape-anchor ؛ لحنِ سه‌سطحی (دبستان/متوسطه‌ی اول/دبیرستان)؛ متنِ ساده بی‌LaTeX؛ حلِ مرحله‌به‌مرحله با وارسی و «✅ جواب»؛ ۶ پیامِ آخرِ گفتگو
جوابِ درست/غلطِ دانش‌آموز generation.py → question_focus با questionId دستیار صورتِ سؤال و آخرین جوابِ دانش‌آموز را می‌بیند و می‌گوید اشتباه کجا بود
تمیزکاری mathtext.py هرچه LaTeX از دستِ مدل در برود به یونیکد برمی‌گردد ( x²، √3 )
شکل diagram.py ← فرانت src/diagram/ مدل فقط توصیفِ JSON می‌نویسد؛ سرور جدا، تعمیر و با فهرستِ سفید وارسی می‌کند؛ اپ مختصات را حساب و SVG رسم می‌کند. ۱۵ نوع شکل — شرحِ کامل در کادرِ بنفشِ پایین.
ثبت ai_tutor/models.py → Message مدل، نسخه‌ی پرامپت، تعدادِ تکه، تأخیر و شکل برای هر جواب ذخیره می‌شود ← کیفیت قابلِ اندازه‌گیری است

مهم‌ترین ایده‌ی فنیِ دستیار

شکل را هوشِ مصنوعی نمی‌کشد: مدل می‌گوید «چه شکلی»، اپ آن را با فرمول می‌کشد

هوشِ مصنوعی ریاضی را می‌فهمد ولی در حسابِ دقیقِ مختصات اشتباه می‌کند؛ کامپیوتر برعکس، هیچ‌وقت در حساب اشتباه نمی‌کند ولی مسئله را نمی‌فهمد. پس کار بینِ این دو تقسیم شد: مدل فقط توصیفِ شکل را به‌صورتِ داده (JSON) می‌نویسد، سرور آن را وارسی می‌کند، و کدِ اپ رأس‌ها، ریشه‌ها و زاویه‌ها را خودش حساب و رسم می‌کند.

نامِ این الگو در مهندسیِ نرم‌افزار: «توصیفِ اعلانی + رسمِ قطعی» (Declarative Spec + Deterministic Rendering) — همان ایده‌ی پشتِ Vega-Lite و Mermaid، این‌جا مخصوصِ کتاب‌های ریاضیِ ایران، با ۱۵ نوع شکل.

گامِ ۱ · سرور → مدل

سرور به مدل می‌گوید اگر شکل لازم است، فقط JSON بنویسد — و فقط بخشِ مربوط از راهنمای شکل‌ها را کنارِ سؤال می‌گذارد (برای «۳۴۵ × ۱۲» هیچ). rag/services/diagram.py

متنِ واقعیِ قاعده (DIAGRAM_RULE): «در پایانِ پاسخ دقیقاً یک بلوکِ <diagram>…</diagram> بگذار که داخلش فقط یک JSON است، نه SVG و نه HTML. برای حسابِ عددی یا ساده‌کردنِ عبارتِ جبری که شکلی ندارد، شکل نگذار.»

گامِ ۲ · مدل

مدل حلِ قدم‌به‌قدم را می‌نویسد و در آخر فقط توصیفِ شکل (~۱۵۰ توکن، نه صدها خط کدِ تصویر):

… ✅ جواب: BC = 10
<diagram>{"type":"triangle", "names":["A","B","C"],
 "sides":{"AB":6,"AC":8}, "angles":{"A":90}, "labels":{"BC":"x"}}</diagram>

گامِ ۳ · سرور

سرور بلوک را از متن جدا، اشتباه‌های قالبیِ مدل را تعمیر، و با فهرستِ سفید وارسی می‌کند (رقمِ فارسی در عدد، 2*pi، کامنت، گیومه‌ی جاافتاده؛ نوع و رنگ و کلیدِ ناشناخته رد؛ عددها محدود). شکلِ خراب فقط حذف می‌شود و جوابِ متنی سرِ جایش می‌ماند. نتیجه در Message.diagram ذخیره و در رویدادِ done فرستاده می‌شود. کارِ سرور ~۰٫۴ میلی‌ثانیه.

# rag/services/diagram.py
def extract(text) -> tuple[str, dict | None]:
    """(text without the block, spec or None)"""
    match = _BLOCK.search(text)            # <diagram>(.*?)</diagram>
    spec = parse(match.group(1)) if match else None   # _repair → json → _normalize → validate
    return _BLOCK.sub('', text).rstrip(), spec

گامِ ۴ · اپ (گوشی)

اپ هر عددی را که قابلِ حساب است خودش حساب می‌کند و SVG می‌کشد : رأس‌های مثلث با قانونِ کسینوس‌ها، ریشه‌ی نمودار با نصف‌کردنِ بازه تا دقتِ 10⁻¹¹. رسم روی گوشیِ خودِ دانش‌آموز انجام می‌شود، پس هزار کاربر یعنی بارِ صفر روی سرور. src/diagram/shapes.ts src/diagram/expr.ts

// src/diagram/shapes.ts — law of cosines: third side from two sides + angle
s[z] = Math.sqrt(s[x] ** 2 + s[y] ** 2 - 2 * s[x] * s[y] * Math.cos(g[opp[z]] * RAD));
// src/diagram/expr.ts — roots: scan 800 points, bisect where the sign flips
if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f(r)) < 1e-6) out.push(r); }

مثالِ ۱ · مثلثِ قائم‌الزاویه

مدل می‌نویسد

{"type":"triangle","names":["A","B","C"],
 "sides":{"AB":6,"AC":8},"angles":{"A":90},
 "labels":{"BC":"x"}}

اپ حساب می‌کند

قانونِ کسینوس‌ها: BC² = 6² + 8² − 2·6·8·cos 90° = 100
پس BC = 10 و رأس‌ها A(0,0) · B(6,0) · C(0,8)

دانش‌آموز می‌بیند

مثلث قائم الزاویه رسم‌شده

مثالِ ۲ · ریشه و رأسِ سهمی

مدل می‌نویسد

{"type":"function","expr":"x^2-5*x+4",
 "marks":["roots","vertex"]}

اپ حساب می‌کند

۸۰۰ نقطه بررسی می‌شود؛ هر جا علامت عوض شد، با نصف‌کردنِ بازه ریشه پیدا می‌شود: x = 1 و x = 4
جایی که شیب صفر است، رأس: (2.5, −2.25)

دانش‌آموز می‌بیند

نمودار سهمی با ریشه‌ها و رأس

روشِ رایج: مدل خودش SVG/HTML بنویسد روشِ ما: مدل توصیف، اپ رسم
امنیت کدِ مدل در صفحه اجرا می‌شود؛ متنِ مخرب در سؤال می‌تواند کد تزریق کند هیچ کدی از مدل اجرا نمی‌شود؛ فقط JSONِ وارسی‌شده
دقت مختصات را حدس می‌زند؛ پنج‌ضلعی کج، ریشه جابه‌جا با فرمول حساب می‌شود
هزینه چند برابر توکن برای هر شکل ~۱۵۰ توکن؛ برای سؤالِ بی‌شکل صفر (۰٫۰۰۰۳ تا ۰٫۰۰۰۸ دلار در هر جوابِ شکل‌دار)
اگر خراب شد صفحه یا چت به‌هم می‌ریزد فقط شکل نمی‌آید؛ جوابِ متنی سالم می‌ماند

چتِ واقعی روی سایت: سهمی چتِ واقعی: ذوزنقه چتِ واقعی: الگوی چوب‌کبریت

فایل‌ها — بک‌اند: rag/services/diagram.py (قاعده، تشخیص، تعمیر، وارسی) · rag/services/generation.py (پرامپت، جدا کردن در استریم) · ai_tutor/models.py → Message.diagram · تست‌ها rag/tests_diagram.py — فرانت: src/diagram/index.tsx (انتخابِ نوع، محافظِ خطا) · plane.tsx · shapes.ts · expr.ts

۹-۶ کیفیت با عدد

سنجش نتیجه
انتخابِ مدلِ جواب: ۱۰۲ مسئله‌ی تصحیح‌شده‌ی دستی gpt-4.1-mini : ۹۵–۹۶ درست · gpt-4o-mini : ۸۵–۹۰
شکل‌ها: ۶۴ مسئله‌ی تصادفیِ کتاب، وارسیِ چشمیِ تک‌تک ۴۰ درست، ۱۲ قابلِ قبول، ۷ غلط، ۵ بی‌شکل — حدودِ یکی از پنج هنوز ایراد دارد؛ برای همین شکل همیشه کنارِ حلِ متنی می‌آید
سرعت جوابِ کامل با شکل ~۳۰ ثانیه؛ اندازه‌گیری شد که در خودِ GapGPT است («فقط بگو سلام» = ۷ ثانیه)
سقف‌ها ۱۵ سؤال در روز برای هر کاربر؛ ۲۰ در ساعت؛ مهمان‌ها بر اساسِ IP (تا با ساختِ حسابِ مهمانِ تازه دور زده نشود)

۱۰. محتوای آموزشی و ساختِ سؤال

هدف: محتوا بدونِ برنامه‌نویس قابلِ ویرایش باشد، و یک سرورِ خالی با یک دستور پر شود.

محتوا کجا زندگی می‌کند

فایل محتوا
content/seed/curriculum.json ۸ پایه، ۵۸ فصل، ۲۶۳ جزیره، ۲٬۶۱۷ درس
content/seed/questions.json ۲٬۵۴۵ سؤالِ چهارگزینه‌ای
  • seed_content در هر انتشار اجرا می‌شود؛ هیچ‌وقت چیزی پاک نمی‌کند و اجرای دوباره‌اش بی‌خطر است.
  • سؤال با مسیرِ عنوان (پایه › فصل › جزیره › درس) به درس وصل می‌شود، نه با شناسه — چون شناسه‌ها بینِ دو دیتابیس فرق دارند.
  • متنی که ادمین در پنل نوشته با انتشارِ بعدی بازنویسی نمی‌شود؛ seed فقط خانه‌ی خالی را پر می‌کند.

کارهای روزمره از پنلِ ادمین

کار کجا
افزودن یا ویرایشِ یک سؤال /admin/content/question/ ← «افزودن»
واردکردنِ فله‌ای (همه یا هیچ) همان صفحه ← «وارد کردنِ سؤال‌ها از فایل»
خروجیِ سؤال یا برنامه‌ی درسی انتخاب ← اکشنِ «دریافتِ فایل» (همان قالبی که ورودی می‌پذیرد)
پایه‌ی تازه /admin/content/grade/add/ — عنوانِ «پایه چهارم»، شناسه خودکار ۴
پلن، همراه، تیکت، کدِ تخفیف صفحه‌ی اپِ مربوط در پنل
سؤال‌های گزارش‌شده QuestionReport — دانش‌آموز «این سؤال ایراد دارد» زده

سؤال‌ها چطور ساخته شدند

  1. تکه‌های agrees فقط راه‌حل‌های تأییدشده
  2. export generate_questions_from_chunks --export رایگان
  3. نویسنده‌ی سؤال subagentهای Claude (Sonnet) با همان قواعدِ پرامپت
  4. import با اعتبارسنجی content/question_io.py
  5. questions.json seed_content --export و commit

بازده حدودِ ۸۷–۹۲٪ بود؛ بقیه عمداً رد شدند (بیشتر مسئله‌هایی که بی‌شکلِ کتاب خودکفا نمی‌شوند). همین مسیر با API پولی هم ممکن است ولی اندازه‌گیری‌شده ~۰٫۷۷ سنت برای هر سؤال گران‌تر بود بی‌کیفیتِ بهتر. نویسنده‌های سؤال چند بار خطای حسابِ خودِ کتاب را هم گرفتند.

پوششِ فعلی: هر جزیره‌ی هر ۸ پایه دستِ‌کم ۴ سؤال دارد. ۵۹۰ درس و ۵۸ فصل متنِ معرفی و مثال دارند ( fill_lesson_texts، بی‌هزینه، هرگز همان مسئله‌ی آزمون).

۱۱. گیمیفیکیشن و همراه‌ها

سازوکارهای شناخته‌شده‌ی اپ‌های آموزشیِ موفق (مانندِ دولینگو): مسیرِ مرحله‌به‌مرحله، امتیاز، قلب، زنجیره‌ی روزانه، رقابت و شخصیت‌هایی که با دانش‌آموز واکنش نشان می‌دهند. همه در سرور حساب می‌شوند، نه در گوشی، تا قابلِ تقلب نباشند.

همراه‌ها

همراه‌ها

شش حالتِ چهره‌ی هر همراه؛ اپ بر اساسِ نتیجه یکی را نشان می‌دهد (سرور در پاسخِ هر جواب mascotMood می‌فرستد):

حالت‌های چهره

بخش چطور کار می‌کند
کاتالوگ جدولِ Character در progress/models.py ؛ ادمین می‌سازد و آستانه‌ی unlock_xp را تعیین می‌کند (مثلِ پلن‌ها).
باز شدن UserStats.xp ≥ unlock_xp — جدولِ مالکیتِ جدا ندارد؛ ساده و بی‌خطا.
انتخاب POST /api/characters/select فقط همراهِ بازشده را می‌پذیرد و در UserStats.selected_character_id می‌نشاند.
تصویر هیچ فایلِ تصویری نیست: src/mascots/seyar.ts هر همراه و هر حالت را از کد به SVG می‌سازد (تصاویرِ بالا همان خروجی‌اند) ← حجمِ کم، واضح روی هر صفحه.

قاعده‌های بازی

سازوکار قاعده کجای کد
امتیاز (XP) پایانِ درس یا جزیره: ۱۰ × جوابِ درست، فقط بارِ اول. عددِ جوابِ درستی که اپ می‌فرستد به جواب‌های درستِ ثبت‌شده محدود می‌شود؛ صدا زدنِ مستقیمِ API امتیازِ مجانی نمی‌دهد. content/views.py → NodeQuizCompleteView
ستاره ۹۰٪ به بالا ۳ · ۶۰٪ به بالا ۲ · بیشتر از صفر ۱ همان
سکه ۵ × ستاره، فقط بارِ اول همان
قلب ۵ قلب؛ هر جوابِ غلط یکی کم؛ هر ۵ ساعت یکی برمی‌گردد و ۲۴ ساعت بعد از اولین قلبِ ازدست‌رفته همه پر می‌شوند. مشترکِ فعال قلب از دست نمی‌دهد. progress/models.py → UserHearts
زنجیره (Streak) جواب، پایانِ درس یا جزیره امروز را (به وقتِ تهران) روزِ فعال می‌کند. اگر آخرین روزِ فعال نه امروز باشد نه دیروز، صفر نشان داده می‌شود. progress/services.py
جدولِ رتبه هفتگی (امتیازِ ۷ روزِ اخیر) و همه‌ی زمان‌ها؛ زنده حساب و ۶۰ ثانیه کش می‌شود. progress/leaderboard.py
پیشرفت روی نقشه پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی ( nextNodeId ) باز می‌شود؛ نمره فقط ستاره و امتیاز را تعیین می‌کند (تصمیمِ محصولی: ساده بماند). content/services.py
اعلان «جزیره را با ۳ ستاره تمام کردی و ۴۰ امتیاز گرفتی» — با رقمِ فارسی. notifications/services.py

محدودیتِ قلب در سرور فعلاً خاموش است ( HEARTS_ENFORCED=0 ): با صفر قلب، اپ جلوی کاربر را می‌گیرد ولی API هنوز جواب را می‌پذیرد، چون فرانت صفحه‌ی «قلب‌ها تمام شد» را ندارد. روشن کردنش یک خط در .env است. تنها مزیتِ واقعیِ اشتراک در کد فعلاً همین «قلبِ نامحدود» است — یک تصمیمِ محصولی.

۱۲. امنیت و احرازِ هویت

دو دور بازبینیِ بدبینانه روی کلِ بک‌اند انجام شد (مرداد و ۳ مهر)، و یک وارسیِ نهایی روی سایتِ زنده (۷ مهر) با این سؤال: «یک دانش‌آموزِ متقلب، یک مهاجم یا یک والدِ سردرگم چطور این را می‌شکند؟» هر یافته با یک تست بسته شد. این بخش صادقانه می‌گوید چه بسته است و چه هنوز باز.

۱۲-۱ هویت و توکن

  1. کدِ پیامکی ۶ رقم، تصادفیِ رمزنگارانه؛ ۵ حدسِ غلط = باطل
  2. access token user_id:زمان:امضا ۱ ساعت؛ در دیتابیس نیست
  3. refresh token در جدولِ Session ؛ ۳۰ روز؛ قابلِ باطل کردن
  4. خروج tokens_valid_after : هر توکنِ قدیمی‌تر رد می‌شود

کاربرهای اپ ( accounts.User ) عمداً از کاربرهای پنلِ ادمین (کاربرِ استانداردِ جنگو، با رمز) جدا هستند؛ دانش‌آموز هیچ راهی به پنل ندارد.

۱۲-۲ آنچه رعایت شده

حوزه چه کرده‌ایم کجا
دسترسی به داده‌ی دیگران شناسه‌ی کاربر همیشه از توکن خوانده می‌شود، هرگز از ورودی؛ دیدنِ پیشرفتِ فرزند فقط با اتصالِ تأییدشده. همه‌ی views.py ها
سقفِ نرخ روی همه‌ی نقاطِ حساس (ورود، دستیار، خرید، کدِ تخفیف، جواب…)؛ مهمان بر اساسِ IPِ واقعی ( NUM_PROXIES=2 : آروان + nginx). config/settings.py
پیامک و هزینه‌اش شماره به 09XXXXXXXXX نرمال می‌شود؛ فاصله بینِ دو درخواست، ۱۰ در روز برای هر شماره، ۳۰۰ در روز برای کلِ سامانه (ضدِ SMS pumping) — شمارش در دیتابیس، نه حافظه‌ی موقت. accounts/phone.py · tests_otp_limits.py
پرداخت فقط تأییدِ سرور-به-سرور؛ برگشتِ جعلی اثری ندارد؛ webhookِ عمومیِ ناامن حذف شد؛ هر درگاهی بی‌کلید «بسته» می‌ماند (fail-closed)، نه موفقیتِ ساختگی. payment/
تقلبِ امتیاز امتیاز فقط برای جوابِ ثبت‌شده؛ جایزه فقط بارِ اول. content/views.py
درخواستِ تکراری و هم‌زمانی Idempotency-Key در شش نقطه؛ transaction.atomic ؛ قیدِ یکتا در دیتابیس؛ SQLite در حالتِ IMMEDIATE تا قفل‌ها واقعاً قفل کنند. config/settings.py
خطا و لاگ هر خطا JSONِ یکدست بی‌جزئیاتِ داخلی؛ لاگ بدونِ بدنه، هدر، توکن یا رمز. config/exceptions.py · middleware.py
محتوای مدل HTML یا SVGِ تولیدِ مدل هرگز واردِ صفحه نمی‌شود؛ فقط JSONِ وارسی‌شده با فهرستِ سفید. rag/services/diagram.py
مرورگر CORS فقط برای دامنه‌ی فرانت؛ پاسخِ API با no-store در کشِ آروان نمی‌ماند؛ نامِ نمایشی از شماره و لینک پاک می‌شود؛ سقفِ حدسِ رمزِ ادمین برای هر IP. config/middleware.py
کلیدها همه در .env ِ سرور؛ هیچ کلیدی در git یا در پاسخِ API نیست؛ /api/health فقط «بله/خیر» می‌گوید. .env.example

۱۲-۳ آنچه عمداً باز مانده (پیش از کاربرِ واقعی باید بسته شود)

این موارد در مرحله‌ی نمایشی (دمو) برای کارِ تیم عمداً باز گذاشته شده‌اند؛ فهرستِ کامل با خطر و راهِ بستنِ هرکدام در docs/security-before-production.md است (۱۸ مورد).

# مورد خطر راهِ بستن
۱ کلیدِ امضای توکن روی سرور همان کلیدِ پیش‌فرضِ داخلِ مخزن است؛ هر کس مخزن را ببیند می‌تواند توکن جعل کند. بحرانی حذف از .env تا تنظیمات خودش کلیدِ تصادفی بسازد (کد آماده است)؛ همه یک بار دوباره وارد می‌شوند.
۲ ۷ شماره‌ی دمو با کدِ ثابت روی سرورِ واقعی بالا پاک کردنِ TEST_LOGIN_PHONES
۳ داده و حساب‌های دمو و آزمایشی در دیتابیسِ واقعی بالا پاک‌سازی پس از بکاپ
۴ دسترسیِ سرور: حسابِ مشترک با رمز و sudoِ کامل بالا کلیدِ SSH و حسابِ محدود — کارِ ادمینِ سرور
۵ /api/health، Swagger و جدولِ رتبه بی‌ورود عمومی‌اند؛ پنلِ ادمین روی دامنه‌ی عمومی متوسط محدود کردن با ورود یا IP
۶ سرور بی‌آروان هم روی پورتِ ۸۰ جواب می‌دهد (امکانِ جعلِ IP برای دور زدنِ سقف) متوسط بستنِ پورت جز برای آروان — ادمینِ سرور
۷ کشِ مشترک (Redis) تنظیم نشده؛ سقف‌های نرخ برای هر worker جداست متوسط CACHE_URL روی Redisِ موجودِ سرور
۸ کدِ پیامکی و refresh token بی‌هش در دیتابیس؛ توکن در localStorage ِ مرورگر کم هش کردن؛ کوکیِ httpOnly
۹ آزمونِ جزیره قفل بودنِ جزیره را نمی‌سنجد (می‌شود جزیره‌ی جلوتر را با API تمام کرد) کم بررسیِ جزیره‌ی قبلی در NodeQuizCompleteView

جمع‌بندیِ وضعیتِ امنیت

لایه‌ی برنامه بازبینی و تست شده است: دسترسی، سقفِ نرخ، پرداخت، تکرارِ درخواست و تقلبِ امتیاز بسته‌اند. چیزهایی که باز مانده عمدی و مستند است — مهم‌ترینِ آن‌ها کلیدِ امضای سرور است که کدِ رفعش آماده است و اعمالش به زمانی موکول شده که خروجِ همه‌ی کاربرها از حساب مشکلی ایجاد نکند. بخشی از موارد نیز به زیرساخت مربوط است و بر عهده‌ی ادمینِ سرور است.

۱۳. استقرار (Deploy) و نگهداری

هدف: هیچ مرحله‌ی دستیِ یادآوردنی در انتشار نباشد. یک دستور، و اسکریپت خودش ثابت می‌کند که کار شد.

۱۳-۱ انتشارِ بک‌اند

ssh -t administrator@<server> 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
  1. ۱ · fetch از Gitea
  2. ۲ · reset تغییرِ دستی ← patch در .deploy-backups/
  3. ۳ · pip install
  4. ۴ · migrate
  5. ۵ · seed_content محتوا
  6. ۶ · collectstatic
  7. ۷ · restart + اثباتِ ری‌استارت
  8. ۸ · health poll تا سالم
  • سرور را تعمیر می‌کند، جلویش نمی‌ایستد: فایلی که کسی دستی روی سرور عوض کرده ذخیره و بعد دور ریخته می‌شود؛ سرور دقیقاً روی چیزی می‌رود که در Gitea است.
  • ری‌استارت را ثابت می‌کند: زمانِ شروعِ سرویس را قبل و بعد مقایسه می‌کند. یک بار سرور دو روز کدِ قدیمی سرو کرد درحالی‌که همه‌ی فایل‌ها تازه بودند — از آن به بعد «فایل روی دیسک» را «منتشرشده» حساب نمی‌کنیم.
  • ری‌استارت جداست چون sudo داخلِ اسکریپت نمی‌تواند رمز بپرسد.

۱۳-۲ انتشارِ فرانت

ssh -t administrator@<server> 'cd ~/mathapp_frontend_new && ./deploy.sh'

fetch ← npm ci ← بررسیِ نوع‌ها ← build در dist.next ← جابه‌جاییِ بی‌وقفه؛ buildِ قبلی در dist.prev می‌ماند و برگشت یک دستور است. نشانیِ API در dist/config.js است و بی‌build عوض می‌شود.

۱۳-۳ پیکربندی ( .env ِ سرور)

متغیر اگر نباشد
PUBLIC_BASE_URL · FRONTEND_URL دامنه‌های فعلی. همه‌ی تنظیماتِ دامنه (CORS، callbackِ زرین‌پال، …) از همین دو ساخته می‌شوند؛ جابه‌جاییِ دامنه یعنی عوض کردنِ همین دو.
DJANGO_SECRET_KEY یک کلیدِ تصادفی ساخته و در .django_secret_key نگه داشته می‌شود (حالتِ مطلوب).
OPENAI_API_KEY · QDRANT_API_KEY دستیار جوابِ ثابتِ نمایشی می‌دهد.
KAVENEGAR_API_KEY · KAVENEGAR_OTP_TEMPLATE درخواستِ کد ۵۰۳ صریح می‌گیرد. الگو باید SMSVerify بماند.
ZARINPAL_MERCHANT_ID · BAZAAR_* پرداخت بسته می‌ماند (۵۰۳).
CACHE_URL کشِ جدا برای هر worker؛ /api/health هشدار می‌دهد.
HEARTS_ENFORCED · TEST_LOGIN_PHONES خاموش · هیچ حسابِ آزمایشی.
DJANGO_DEBUG خاموش — و هرگز روی سرور روشن نشود (کدِ ورودِ همگانیِ 0000 را فعال می‌کند).

مقدارهای .env با پیش‌فرض‌های کد ادغام می‌شوند؛ یک .env ِ ناقص سرویس را نمی‌خواباند. شرحِ کامل در .env.example و docs/operations.md.

۱۳-۴ پایش، بکاپ و قواعدِ سرورِ مشترک

GET /api/health

بی‌ورود و بی‌SSH می‌گوید: دیتابیس سالم است؟ migrationِ جامانده هست؟ کش کار می‌کند؟ چند پایه/درس/سؤال؟ کدام سرویسِ بیرونی تنظیم است؟ اعتبارِ دستیار تمام شده ( aiProvider )؟ هشدارهای امنیتی و ترافیکِ اخیر. ۲۰۰ = سالم، ۵۰۳ = خرابیِ واقعی.

بکاپ

دیتابیس در git نیست و تنها نسخه‌ی داده‌ی کاربرهاست. بکاپ با sqlite3.Connection.backup() ِ پایتون (نه cp ) پیش از هر انتشارِ مهم، در .deploy-backups/. هنوز خودکار نیست — ارزشمندترین کارِ بعدی.

سرور مالِ ما نیست؛ ما یکی از مستأجرهاییم

  • مالِ ما فقط /home/administrator/mathapp-v2 و سرویسِ zareian-mathapp است. gunicorn ِ خالی سرویسِ تیمِ دیگری است.
  • هرگز git clean یا «پاک کن و از نو clone کن»: db.sqlite3، .env و کلیدِ امضا در همان پوشه‌اند و در git نیستند.
  • پیش از هر کارِ پرحجم df -h / : دیسک حدودِ ۹۵٪ پر است و پر شدنش Gitea و ایمیلِ شرکت را هم می‌خواباند.
  • .env فقط از راهِ جنگو خوانده شود؛ مقدارش هرگز در چت یا گزارش نیاید.

۱۴. مقیاس‌پذیری و پیوستنِ توسعه‌دهنده‌ی تازه

دو معنا دارد: کد چقدر برای تیمِ بزرگ‌تر آماده است، و سیستم چقدر برای کاربرِ بیشتر.

۱۴-۱ آیا دو سه نفرِ دیگر می‌توانند به بک‌اند اضافه شوند؟ بله — به این دلایل

ویژگی چرا کارِ هم‌زمان را ممکن می‌کند
۱۵ اپ با مرزِ روشن هر نفر صاحبِ چند اپ می‌شود (مثلاً یکی پرداخت و اشتراک، یکی محتوا و گیمیفیکیشن، یکی دستیار)؛ تغییرِ هر کس کمتر به فایلِ دیگری می‌خورد.
بی‌FK بینِ اپ‌ها تغییرِ جدول‌های یک اپ migrationِ اپِ دیگر را نمی‌شکند؛ هر اپ بعداً قابلِ جدا شدن به سرویسِ مستقل است.
قراردادِ OpenAPI فرانت و بک‌اند بی‌جلسه روی یک شکلِ داده توافق دارند؛ tests_frontend_contract.py تغییرِ ناخواسته را می‌گیرد.
۶۸۵ تستِ خودکار تازه‌وارد بی‌ترس تغییر می‌دهد: اگر چیزی را شکست، تست قبل از سرور خبر می‌دهد.
قواعدِ یک‌جا نرخ‌ها، دامنه‌ها و تنظیمات فقط در config/settings.py ؛ پیامِ خطا، لاگ و هویت یک مسیرِ مشترک دارند.
مستندات در مخزن docs/ + همین سند + Swagger + توضیحِ «چرا» کنارِ کدِ حساس.

۱۴-۲ دستورالعمل: افزودنِ یک اندپوینتِ تازه

  1. اپِ درست را انتخاب کنید (یا اپِ تازه با python manage.py startapp و افزودن به INSTALLED_APPS و config/urls.py ).
  2. مدل در models.py — ارجاع به اپِ دیگر فقط با user_id یا شناسه‌ی عددی — و python manage.py makemigrations.
  3. ورودی/خروجی در serializers.py، با نامِ camelCase.
  4. ویو در views.py با permission_classes ؛ اگر حساس است throttle_scope و ردیفِ متناظر در DEFAULT_THROTTLE_RATES ؛ اگر اثرِ ماندگار دارد، Idempotency-Key و transaction.atomic ؛ تماسِ شبکه‌ای بیرون از تراکنش.
  5. مسیر در urls.py ِ اپ و @extend_schema تا در Swagger بیاید.
  6. تست در tests.py — از جمله «کاربرِ دیگری نمی‌تواند».
  7. python manage.py test ← push ← deploy.sh ← /api/health.
  8. فرانت: نوع در src/api/types.ts از روی پاسخِ واقعی، تابع در src/api/index.ts.

۱۴-۳ مقیاسِ اجرا: کجا گلوگاه است و بعد چه

وضعِ فعلی: ۲۴ درخواستِ هم‌زمان؛ درخواستِ عادی چند ده میلی‌ثانیه، جوابِ دستیار ده‌ها ثانیه. توکن stateless است، پس هر تعداد نسخه‌ی دیگر از بک‌اند پشتِ nginx بی‌تغییرِ کد کار می‌کند — به شرطِ دیتابیس و کشِ مشترک.

# گلوگاه راه‌حلِ ساده‌ی بعدی نیاز
۱ SQLite فقط یک نویسنده دارد Postgres (همین حالا روی سرور هست)؛ تغییرِ تنظیمات + انتقالِ داده؛ حالتِ IMMEDIATE برداشته می‌شود و قفلِ ردیف واقعی می‌شود. ادمینِ سرور
۲ کش و سقفِ نرخِ جدا برای هر worker CACHE_URL روی Redisِ موجود (db1 آزاد است). یک خط در .env
۳ جوابِ دستیار نخ را نگه می‌دارد صفِ کار (مثلاً Celery، که RabbitMQ و Redis‌اش روی سرور هست) یا ویوی async برای /api/ai/ask. کد
۴ یک سرور برای همه چند نسخه‌ی gunicorn پشتِ nginx؛ فرانت از CDNِ آروان. زیرساخت
۵ هزینه‌ی دستیار با کاربرِ زیاد کش، سقفِ روزانه (هست)، و سنجشِ مدلِ ارزان‌تر روی همان ابزارِ rag_model_benchmark. سنجش

بهینه‌سازی‌های انجام‌شده: رفعِ N+1 در صفحه‌ی اصلی (از ۱+N کوئری به ۳ کوئریِ ثابت)، کشِ نقشه، کشِ جدولِ رتبه (۶۰ ثانیه)، کشِ جوابِ دستیار، ادغامِ چند درخواست در یکی ( /api/home، /api/user/summary، /api/nodes/{id}/quiz )، و حجمِ بارِ اولِ فرانت از ۵۰۵ به ۸۵ کیلوبایت.

۱۵. تست و کیفیت

چه چطور
تستِ خودکارِ بک‌اند ۶۸۵ تست، همه سبز ( python manage.py test ). روی فایلِ دیتابیسِ واقعی ( .test-db.sqlite3 ) اجرا می‌شوند تا رفتارِ قفل‌ها مثلِ سرور باشد. هر باگِ پیداشده در بازبینی‌ها یک تستِ اختصاصی دارد ( tests_audit_fixes.py، tests_otp_limits.py، …).
قرارداد با فرانت content/tests_frontend_contract.py و build با TypeScriptِ strict در فرانت.
تستِ روی سایتِ زنده هر صفحه با حساب‌های نمایشی روی سرورِ واقعی؛ مرورگرِ خودکار (puppeteer) برای وارسیِ صفحه‌ها.
کیفیتِ دستیار rag_model_benchmark (مسئله‌های واقعیِ کتاب از مسیرِ واقعی، با هزینه‌ی واقعی)، ai_feedback_report (👍/👎 به تفکیکِ نسخه‌ی پرامپت)، وارسیِ چشمیِ نمونه پیش از اعتماد به هر اجرای انبوه.
بی‌هزینه آزمودن tools/fake_openai.py جای GapGPT می‌نشیند.
پایش لاگِ JSONِ هر درخواست + /api/health ؛ محلی با Grafana/Loki ( docs/docker.md ).

فرهنگِ کد: توضیح‌ها کنارِ کد «چرا» را می‌گویند، نه «درست است». قاعده‌ی مخزن: کامنت ادعاست، نه مدرک — هر ادعا با تست یا اجرا سنجیده می‌شود. چند باگِ واقعی دقیقاً زیرِ کامنتی پیدا شد که کد را «امن» می‌خواند.

۱۶. وضعیتِ فعلی، محدودیت‌ها و تصمیم‌های باز

وضعیت در تاریخِ نگارشِ سند (۷ مهر ۱۴۰۵، روی سرورِ زنده)

بخش وضعیت توضیح
اپ و API زنده هر دو روی آخرین نسخه؛ همه‌ی صفحه‌ها با حساب‌های نمایشی وارسی شد
محتوا کامل ۸ پایه؛ هیچ جزیره‌ای زیرِ ۴ سؤال نیست
پیامکِ ورود کار می‌کند کدِ واقعی به گوشی رسید (الگوی SMSVerify)؛ هر پیامک هزینه دارد
دستیار + شکل کار می‌کند کُند (~۳۰ ثانیه، از سمتِ GapGPT)؛ ~یک شکل از پنج ایراد دارد
زرین‌پال نیمه درخواستِ پرداخت پذیرفته می‌شود؛ پرداختِ واقعی تا انتها آزموده نشده
کافه‌بازار منتظر کد آماده؛ منتظرِ تأییدِ حسابِ شرکت و اپِ اندرویدی
پیامِ گروهی پایه ذخیره و هدف‌گیری هست؛ زمان‌بندیِ خودکار نه
بکاپِ خودکار ندارد دستی پیش از هر انتشار

تصمیم‌هایی که با مدیریت است، نه با کد

موضوع چرا تصمیم لازم است
عوض کردنِ کلیدِ امضا کد آماده؛ همه یک بار از حساب بیرون می‌افتند — زمانش را باید تعیین کرد.
پاک‌سازیِ داده‌ی دمو پیش از اولین کاربرِ واقعی: ۷ شماره‌ی آزمایشی، حساب‌های دمو و مهمان‌های تستی.
مزیتِ اشتراک در حالِ حاضر فقط «قلبِ نامحدود»؛ دستیار و درس‌ها برای همه آزادند. چه چیزی پولی باشد؟
Redis و Postgres هماهنگی با ادمینِ سرور؛ هر دو روی همان ماشین موجودند.
پورتِ ۸۰ و دسترسیِ SSH کارِ ادمینِ سرور.
پیشرفت با هر نمره در حالِ حاضر جزیره با هر نمره‌ای تمام می‌شود؛ اگر حدِ قبولی لازم است، یک تصمیمِ آموزشی است.

کارِ بعدی، به ترتیبِ ارزش

  1. بستنِ موارد ۱ تا ۳ِ امنیت و پاک‌سازیِ داده‌ی دمو.
  2. بکاپِ خودکارِ دیتابیس، بیرون از همان سرور.
  3. یک پرداختِ واقعیِ کامل با زرین‌پال.
  4. CACHE_URL (Redis) و بعد Postgres.
  5. صفِ کار برای دستیار؛ سنجشِ شکل روی نمونه‌ی بزرگ‌تر.
  6. روشن کردنِ HEARTS_ENFORCED همراه با صفحه‌ی «قلب‌ها تمام شد» در فرانت.

پیوست

الف · واژه‌نامه

واژه معنی در این پروژه
API / اندپوینت نشانی‌ای که اپ برای گرفتن یا فرستادنِ داده صدا می‌زند، مثلِ GET /api/home.
اپِ جنگو یک پوشه‌ی مستقل با مدل، ویو و تستِ خودش؛ یک مسئولیت.
مدل / migration تعریفِ یک جدول در پایتون / اسکریپتی که تغییرِ جدول را روی دیتابیس اعمال می‌کند.
ویو / serializer کدی که درخواست را جواب می‌دهد / کدی که شکلِ ورودی و خروجی را تعریف و وارسی می‌کند.
middleware لایه‌ای که همه‌ی درخواست‌ها پیش و پس از ویو از آن رد می‌شوند.
توکن «کارتِ شناسایی»ِ امضاشده که اپ با هر درخواست می‌فرستد.
throttle سقفِ تعدادِ درخواست در زمان.
Idempotency-Key کلیدی که باعث می‌شود تکرارِ یک درخواست دو بار اثر نکند.
تراکنشِ اتمیک چند نوشتن که یا همه انجام می‌شوند یا هیچ‌کدام.
RAG اول پیدا کردنِ متنِ مرتبط از کتاب، بعد نوشتنِ جواب بر پایه‌ی آن.
embedding / بردار تبدیلِ متن به عددهایی که معنا را نشان می‌دهند تا متن‌های هم‌معنا کنارِ هم بیفتند.
chunk / collection یک تکه‌ی کتاب (یک تیپ‌مسئله) / همه‌ی تکه‌های یک کتاب در Qdrant.
SSE (استریم) فرستادنِ جواب تکه‌تکه، تا کاربر پیش از تمام شدن چیزی ببیند.
seed پر کردنِ دیتابیس از فایل‌های محتوای داخلِ مخزن.
fail-closed اگر تنظیمات نبود، بسته بماند (خطای صریح)، نه اینکه وانمود کند کار کرد.

ب · دستورهای پرکاربرد

# توسعه‌ی محلی
python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt
cp .env.example .env            # DJANGO_DEBUG=1 برای محلی
python manage.py migrate && python manage.py seed_content && python manage.py runserver
python manage.py test           # کلِ تست‌ها
python manage.py spectacular --file schema.yaml   # قراردادِ API

# محتوا و دستیار
python manage.py seed_content --export --only questions
python manage.py rag_catalog                      # collectionهای Qdrant (فقط خواندن)
python manage.py rag_ingest_book --grade نهم --plan
python manage.py rag_verify_solutions
python manage.py generate_questions_from_chunks --subject ریاضی --grade نهم --export /tmp/b
python manage.py ai_feedback_report
python manage.py rebuild_leaderboard
python manage.py payment_reconcile --days 3

# فرانت
npm install && npm run dev     # http://localhost:5173
npm run build                   # type-check + build در dist/

ج · نقشه‌ی مستنداتِ موجود

سند برای
README.md (هر دو مخزن) درِ ورودی و راه‌اندازی
docs/architecture.md تصمیم‌های معماری و دلیلشان، با جزئیات
docs/operations.md انتشار، متغیرها، بکاپ، جابه‌جاییِ دامنه، ورودِ تیم
docs/security-before-production.md ۱۸ موردِ امنیتیِ باز با خطر و راهِ بستن
docs/content-authoring.md ویرایشِ محتوا و ساختِ انبوهِ سؤال
docs/extraction-playbook.md خواندنِ کتاب با مدلِ بینایی: هزینه و درس‌ها
data/textbooks/README.md افزودنِ کتابِ تازه
/api/docs/ قراردادِ زنده و قابلِ امتحانِ API

تدریسینو · مستندِ فنی · علیرضا زارعیان · مهرِ ۱۴۰۵
عددها از کد، اسکیمای API، دیتابیس و سنجش‌های ثبت‌شده در مخزن استخراج شده‌اند.