Docs brought to the current state: CLAUDE.md rewritten, README formal, handbook and diagrams redrawn

- CLAUDE.md: 1,225 → ~430 lines, current state only (the dated status log stays in git history).
  Server rules moved to the top: what is ours on 249/247 and what is not, non-negotiable rules,
  announce-before-acting, password handling, and the full "deploy it" procedure with verification.
- README: formal entry page with current numbers (10 grades, 18,522 questions, 61 API operations,
  973 tests, PostgreSQL/Redis).
- Removed docs/frontend/ (Figma-era guide with guest login and its own api.ts; the frontend repo has
  the real client). Swagger's external-docs link now points at the frontend repo.
- architecture/operations/security/content-authoring/loadtest/textbooks: SQLite, guest login,
  demo accounts, question packs and the port-80 note replaced by what is true now.
- Technical handbook text updated throughout; all seven diagrams redrawn (PostgreSQL, phone-only
  login, subscriptions, current data model); PDF rebuilt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 18:45:51 +03:30
parent eb95209ea8
commit a4e0c576bb
20 changed files with 752 additions and 2134 deletions

View File

@@ -21,7 +21,7 @@
(`POST /api/auth/logout`) refreshِ همان دستگاه را باطل می‌کند و
`User.tokens_valid_after` را می‌گذارد؛ هر توکنِ access که پیش از آن لحظه صادر شده
رد می‌شود (دقتِ ثانیه‌ای). دستگاه‌های دیگر با refreshِ خودشان بی‌صدا توکنِ تازه
می‌گیرند. پیش از ۲۰۲۶-۰۹-۲۵ خروج وجود نداشت.
می‌گیرند.
**کدِ ورود ۶ رقمی است و هر کد بعد از ۵ حدسِ غلط باطل می‌شود**
(`OTPCode.failed_attempts`، شمارش در دیتابیس، نه در کشِ per-worker).
@@ -53,15 +53,15 @@
`X-Forwarded-For` قابلِ اعتماد است و **باید با تعدادِ واقعیِ پروکسی‌ها یکی
باشد**، وگرنه جعلِ IP و دور زدنِ محدودیت ممکن می‌شود.
روی پروداکشن این عدد **۲** است (پیش‌فرضِ کد از ۲۰۲۶-۰۹-۲۴): ArvanCloud جلوی
سرور است و بعد nginxِ خودِ ماشین. تا آن روز ۱ بود و جنگو IPِ آروان را IPِ کاربر
می‌دید؛ یعنی همه‌ی کاربرانِ بی‌ورود یک سهمِ مشترک داشتند. `clientIp` در `/api/health`
روی پروداکشن این عدد **۲** است: ArvanCloud جلوی سرور است و بعد nginxِ خودِ ماشین. با عددِ کمتر، IPِ آروان
IPِ کاربر دیده می‌شود و همه‌ی کاربرانِ بی‌ورود یک سهمِ مشترک می‌گیرند. `clientIp` در `/api/health` (با کلیدِ داخلی)
نشان می‌دهد سرور صدازننده را با چه IPای می‌شناسد — باید IPِ عمومیِ خودتان باشد.
⚠️ سرور روی پورتِ ۸۰ مستقیم هم (بدونِ آروان) جواب می‌دهد. کسی که IPِ سرور را
بداند می‌تواند از آن راه XFF جعل کند و محدودیتِ IP را دور بزند. ورودِ OTP جدا
به ازای هر شماره هم محدود است، پس این راه به حدسِ کد نمی‌رسد. بستنِ کاملش (پورتِ
۸۰ فقط برای رنج‌های آروان، یا `set_real_ip_from` در nginx) کارِ ادمینِ سرور است.
`X-Forwarded-For` فقط وقتی پذیرفته می‌شود که درخواست واقعاً از ArvanCloud آمده باشد
(`config.middleware.TrustedProxyMiddleware`، فهرستِ `TRUSTED_PROXY_NETWORKS`؛ آدرسِ TCP را nginx در `X-Real-IP`
می‌گذارد). وگرنه کسی که مستقیم به IPِ سرور بزند و در هر درخواست XFFِ تازه بفرستد، از همه‌ی سقف‌های IP-محور رد
می‌شود. لایه‌ی دوم در nginx است: هر درخواستی که از آروان نیامده ۴۰۳ می‌گیرد (`ops/nginx/zareian-arvan-only.conf`).
وقتی آروان رنجِ IPِ تازه بگیرد، هر دو فهرست با هم تازه می‌شوند (`docs/operations.md` «nginx»).
## پرداخت: هرگز مستقیم به درگاه
@@ -78,7 +78,7 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م
**fail-closed** می‌شوند (`503` یا نامعتبر)، نه موفقیتِ ساختگی. همین قاعده برای
پیامک هم برقرار است.
قاعده‌هایی که بازبینیِ ۲۰۲۶-۰۹-۲۵ اضافه کرد (هرکدام با تست در
قاعده‌های پرداخت (هرکدام با تست در
`payment/tests_audit_fixes.py`):
| قاعده | چون پیش‌تر… |
@@ -110,8 +110,8 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م
جستجوی Qdrant محدود به همان درس/فصل → تولید با GPT. تکه‌های استفاده‌شده در
`usedChunks` برمی‌گردند، پس پاسخ قابلِ ردیابی به منبع است.
- نیازمندِ `OPENAI_API_KEY` و `QDRANT_API_KEY`. بدونِ کلید، همان پاسخِ stubِ
قبلی برمی‌گردد (سازگاریِ عقب‌رو).
- نیازمندِ `OPENAI_API_KEY` و `QDRANT_API_KEY`. بی‌کلید و با `DEBUG=0` پاسخ ۵۰۳ است، نه جوابِ ساختگی
(`StubNeverReachesProductionTests`)؛ مسیرِ نمونه فقط با `DEBUG` یا `RAG_FORCE_MOCK` در دسترس است.
- `"stream": true` پاسخ را به‌صورتِ SSE توکن‌به‌توکن می‌فرستد؛ قراردادِ JSON
بدونِ این فلگ دست‌نخورده است.
- `python manage.py rag_catalog` فهرستِ زنده‌ی collectionهای Qdrant را نشان
@@ -122,7 +122,11 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م
کیفیتِ محتوایی که این لایه می‌خواند در
[`content-authoring.md`](content-authoring.md) توضیح داده شده.
### شکلِ پاسخ (پرامپتِ `tutor-v3-stepwise-plain`، ۲۰۲۶-۰۹-۲۵)
### شکلِ پاسخ
پرامپتِ فعلی `tutor-v12-age-tone` است (`rag/services/generation.py`، `PROMPT_VERSION`). جدولِ زیر قاعده‌هایی است که
از `tutor-v3` به بعد در آن مانده‌اند؛ فقط‌ریاضی‌بودن در `docs/ai-cost-and-guardrails.md` و لحن بر اساسِ سن (۱۸+ لحنِ
بزرگسال در هر پایه، کشِ جواب با لحن کلید می‌خورد) در `tone_for`.
| چه | چرا |
|---|---|
@@ -145,7 +149,7 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
هزینه‌ی هر پاسخ از `usage.cost`ِ خودِ GapGPT خوانده می‌شود، و `--context-cache` بخش‌های
بازیابی‌شده را ذخیره می‌کند تا چند اجرا (چند مدل یا چند نسخه‌ی پرامپت) دقیقاً یک context ببینند.
**انتخابِ فعلی: `gpt-4.1-mini` (۲۰۲۶-۰۹-۲۶).** دورِ اول ۱۴ مدل روی ۳۰ مسئله: پنج مدل
**انتخابِ فعلی: `gpt-4.1-mini`.** دورِ اول ۱۴ مدل روی ۳۰ مسئله: پنج مدل
بی‌خطا بودند و 4.1-mini ارزان‌ترینشان بود. دورِ دوم 4o-mini در برابرِ 4.1-mini روی ۱۰۲ مسئله
با سه نسخه‌ی پرامپت: ۹۵–۹۶ در برابرِ ۸۵–۹۰. نوسانِ 4o-mini با پرامپتِ یکسان تا ۵ مسئله بود،
پس این فاصله واقعی است. خطاهای 4o-mini بیشتر حسابِ ساده بود (۸۴۵۱ − ۷۲۳۴ = ۱۳۱۷). هر پاسخ
@@ -172,7 +176,7 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
`/api/health` آن را در `integrations.aiProvider` و `warnings` نشان می‌دهد.
پیش‌تر `openai: true` فقط یعنی «کلید تنظیم شده».
### شکل در جواب (پرامپتِ `tutor-v10-shape-anchor`، ۲۰۲۶-۰۹-۲۹)
### شکل در جواب
مدل HTML یا SVG نمی‌نویسد. در پایانِ جوابِ شکل‌دار یک بلوکِ کوتاهِ `<diagram>{JSON}</diagram>` می‌گذارد
که فقط **توصیف** است («مثلثی با AB = 6، AC = 8 و زاویه‌ی قائمه در A»). فرانت (`src/diagram/`) از روی
@@ -211,9 +215,8 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
آزمونش تمام شده `completed` می‌بیند، و مسیرِ قدیمیِ «همه‌ی درس‌ها تک‌تک» هم هنوز
کار می‌کند.
**جزیره با درس‌ها هم تمام می‌شود، ولی فقط درس‌های سوال‌دار شمرده می‌شوند**
(۲۰۲۶-۰۹-۲۵). ۵۸۵ درس هنوز سوال ندارند و اپ برایشان دکمه‌ی شروع ندارد؛ با شرطِ
«همه‌ی درس‌ها» ۱۸۱ جزیره از ۲۶۹ هرگز تمام نمی‌شد. هر نود در `/api/home`
**جزیره با درس‌ها هم تمام می‌شود، ولی فقط درس‌های سوال‌دار شمرده می‌شوند**؛ با شرطِ «همه‌ی درس‌ها»، جزیره‌ای که
درسِ بی‌سوال دارد هرگز تمام نمی‌شد. هر نود در `/api/home`
`nextLessonId` دارد (اولین درسِ سوال‌دارِ تمام‌نشده) و **عضوِ اولِ `lessonIds`
همیشه همان است** — فرانت با کلیک روی جزیره `lessonIds[0]` را باز می‌کند و پیش‌تر
هر بار درسِ اول را می‌گرفت. جزیره‌ای که یک بار تمام شد (`NodeProgress` یا
@@ -222,7 +225,34 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
`progressPercent` در `/api/home` برای همان پایه‌ای است که نقشه‌اش نشان داده
می‌شود: درس‌های سوال‌دارِ تمام‌شده به‌اضافه‌ی درس‌های جزیره‌های تمام‌شده، تقسیم
بر درس‌های سوال‌دارِ آن پایه. (قبلاً مخرج همه‌ی ۲٬۶۱۷ درسِ همه‌ی پایه‌ها بود.)
بر درس‌های سوال‌دارِ آن پایه.
**آزمونِ هر جزیره ثابت است:** مبنای درهم‌ریزی فقط `nodeId:userId` است، پس دانش‌آموز هر بار همان سوال‌ها را
می‌بیند (تصمیمِ مالک؛ `NodeQuizStabilityTests`).
**پایان فقط برای جزیره‌ی باز و آزمونِ جواب‌داده:** `quiz/complete` و `lessons/{id}/complete` جزیره‌ی قفل را ۴۰۳
`node_locked` می‌دهند (همان قاعده‌ی `/api/home`، `content.views.node_is_unlocked`) و کاربری که همه‌ی سوال‌ها را
جواب نداده ۴۰۹ `answers_missing`. تعدادِ لازم `max(totalQuestions ِ کلاینت، min(4، سوال‌های جزیره))` است تا کلاینت
نتواند کمش کند. جزیره یا درسِ بی‌سوال، اگر باز باشد، بی‌ستاره و بی‌امتیاز رد می‌شود تا زنجیره را نبندد.
## کتاب‌ها و دیوارِ پرداخت
هر فصل (`Unit`) به یک کتاب (`Subject`: ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) تعلق دارد. `/api/home?subject=<key>`
نقشه‌ی همان کتاب را می‌دهد و کتابِ بی‌ربط به پایه ۴۰۰ است؛ `subjects` در پاسخ فقط کتاب‌های همان پایه است.
**فصلِ اولِ هر کتاب رایگان است** (`content.views.unit_is_free`). بیرون از آن، بی‌اشتراکِ فعال، سوال‌های درس، آزمونِ
جزیره، ثبتِ جواب و پایانِ درس/جزیره ۴۰۲ `subscription_required` می‌دهند و `/api/home` برای هر فصل
`requiresSubscription` دارد (`content/tests_paywall.py`).
## متنِ خروجی: ساده و بی‌اعراب
تصمیمِ شرکت: هیچ اعراب و «ی»ِ اضافه‌ی نوشته‌شده («پایه‌ی سوم») به کاربر نمی‌رسد؛ تنوین می‌ماند. این **در خروجی**
انجام می‌شود، نه در داده: `config.renderers.PlainPersianJSONRenderer` روی هر پاسخِ JSON و `rag.services.mathtext.to_plain`
روی پاسخِ دستیار (استریم هم). دیتابیس، seed و ادمین متنِ اصلی را نگه می‌دارند، چون بازنویسیِ عنوان‌ها اتصالِ سوال به
درس (که با مسیرِ عنوان است) و کلیدهای `SeedRecord` را می‌شکست. تست: `config/tests_plain_persian.py`.
هیچ LaTeX یا markdownی هم به دانش‌آموز نمی‌رسد: `content/plain_math.py` روی seed و ورودِ سوال، `to_plain` روی دستیار،
و `plainMath.ts` در فرانت به‌عنوانِ آخرین تور؛ این دو پیاده‌سازی باید هم‌رفتار بمانند.
## دستیار و سوالِ تمرینی
@@ -242,10 +272,6 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
## استریک و جدولِ رتبه‌بندی، زنده از فعالیت
تا ۲۰۲۶-۰۹-۲۴ هیچ‌جای اپ `StreakRecord` نمی‌نوشت و `GET /api/leaderboard` فقط
جدولی را می‌خواند که با اجرای دستیِ `rebuild_leaderboard` پر می‌شد؛ یعنی برای
کاربرِ واقعی «روز پیاپی» همیشه صفر بود و XPاش هیچ‌وقت در جدول دیده نمی‌شد.
- **استریک** (`progress/services.py`): پاسخ به سوال، پایانِ درس و پایانِ جزیره
امروز را (به وقتِ تهران) روزِ فعال ثبت می‌کنند. نمایش با
`effective_current_streak` است: اگر آخرین روزِ فعال نه امروز است نه دیروز،
@@ -260,12 +286,11 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
قلب برمی‌گردد** (`HEART_REFILL_INTERVAL`) و **۲۴ ساعت بعد از اولین قلبی که از
حالتِ پر کم شد، همه یک‌جا پر می‌شوند** (`HEART_FULL_REFILL_AFTER`، فیلدِ
`full_refill_at`). `GET /api/user/hearts` هر دو زمان را می‌دهد: `nextRefillAt`
و `fullRefillAt`. تا ۲۰۲۶-۰۹-۲۴ هر قلب ۲۴ ساعت بود؛ تایمرِ قدیمیِ بلندتر از ۵
ساعت در اولین خواندن کوتاه می‌شود.
و `fullRefillAt`.
**با صفر قلب، بک‌اند جواب را رد می‌کند** (۴۰۳ با `code: "no_hearts"`، `nextRefillAt` و
`fullRefillAt`). `HEARTS_ENFORCED` از ۲۰۲۶-۱۰-۰۲ پیش‌فرض روشن است و فرانت برای این پاسخ صفحه‌ی
«قلب‌هات تموم شد» را با شمارشِ معکوس دارد (`src/components/NoHeartsScreen.tsx`). از همان روز:
`fullRefillAt`). `HEARTS_ENFORCED` پیش‌فرض روشن است و فرانت برای این پاسخ صفحه‌ی
«قلب‌هات تموم شد» را با شمارشِ معکوس دارد (`src/components/NoHeartsScreen.tsx`). سه جزئیات:
- چکِ قلب داخلِ همان تراکنشی است که قلب را کم می‌کند. پیش‌تر بیرونش بود و دو جوابِ غلطِ هم‌زمان با
یک قلبِ باقی‌مانده هر دو پذیرفته می‌شدند (با دو thread بازتولید شد).
- پایانِ درس یا جزیره بی‌قلب و بی‌جواب دادنِ سوال‌هایش ۴۰۳ می‌گیرد؛ پیش‌تر `quiz/complete` اجازه
@@ -283,16 +308,16 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
## دیتابیس
هنوز SQLite است (`OPTIONS.timeout=20` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند).
مهاجرت به Postgres انجام نشده.
روی سرور **PostgreSQL 14** است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost) و بی `DB_NAME` در `.env` برنامه
SQLite را برمی‌دارد (توسعه‌ی محلی و تست‌ها). کلِ تست‌ها روی هر دو سبزند. قفل‌های ردیفی (`select_for_update`) در
پرداخت، سقفِ کدِ تخفیف، قلب و سهمیه‌ی دستیار روی Postgres قفلِ واقعی‌اند؛ روی SQLite همان تراکنش‌ها با
`transaction_mode: IMMEDIATE` سریالی می‌شوند، چون SQLite `FOR UPDATE` را بی‌صدا نادیده می‌گیرد.
**`transaction_mode: IMMEDIATE`** (۲۰۲۶-۰۹-۲۵): هر `atomic()` قفلِ نوشتن را از
اول می‌گیرد. SQLite از `SELECT … FOR UPDATE` پشتیبانی نمی‌کند و جنگو
`select_for_update` را رویش بی‌صدا نادیده می‌گیرد؛ یعنی تا آن روز همه‌ی
«قفلِ ردیف»ها در پرداخت، سقفِ کدِ تخفیف، قلب و سهمیه‌ی دستیار عملاً قفل نبودند.
حالا کلِ تراکنش‌ها سریالی‌اند (درشت ولی درست). فراخوانی‌های شبکه‌ای (AI،
زرین‌پال) بیرون از `atomic()` هستند و باید بمانند. بعد از Postgres این گزینه
برداشته می‌شود.
**اتصال‌ها از poolِ هر worker می‌آیند** (`OPTIONS.pool`، `DB_POOL_MAX_SIZE`): Postgresِ سرور `max_connections=60`
دارد و الگوی «یک اتصال برای هر نخ» (۴ worker × ۳۲ نخ) آن را پر می‌کرد. poolِ پر ۵۰۳ با `Retry-After` می‌دهد، نه ۵۰۰
(`config/db.py`)، و `/api/health` → `dbPool` بودجه‌ی `workers × pool + 6 ≤ max_connections` را می‌سنجد. فراخوانی‌های
شبکه‌ای (AI، زرین‌پال، پیامک) بیرون از `atomic()` هستند و دستیار و پیامک پیش از انتظارِ شبکه اتصال را پس می‌دهند
(`release_db_connection`).
تست‌ها روی فایلِ `.test-db.sqlite3` اجرا می‌شوند، نه دیتابیسِ حافظه‌ای: آن یکی
shared-cache است و قفلش جدول‌به‌جدول و بی‌انتظار است، رفتاری که روی فایلِ واقعی
@@ -302,14 +327,12 @@ shared-cache است و قفلش جدول‌به‌جدول و بی‌انتظا
`Grade.number` هم هست. پایه‌ی تازه id را از شماره‌اش می‌گیرد، نه از شمارنده‌ی
دیتابیس؛ شماره اگر خالی بماند از عنوان («پایه چهارم») خوانده می‌شود. پایه‌ی
بی‌شماره ([DEMO]/[SEED]) idِ بالای ۱۰۰ می‌گیرد. همه‌جا — API، ادمین، فیلترِ
سوال‌ها — پایه‌ها به‌ترتیبِ شماره‌اند. تا migration‌ِ `content.0008` id ترتیبِ
واردشدنِ کتاب‌ها بود (نهم=۱، هشتم=۵).
سوال‌ها — پایه‌ها به‌ترتیبِ شماره‌اند.
⚠️ **`.iterator()` را دورِ حلقه‌ای که می‌نویسد یا I/O بیرونی می‌زند نگذارید.**
کرسرِ خواندن در تمامِ طولِ حلقه باز می‌ماند و روی SQLite جدول را برای هر
نویسنده‌ی هم‌زمان قفل می‌کند — قفلی که تا پایانِ حلقه رها نمی‌شود و هیچ
retryای نجاتش نمی‌دهد. این یک بارْ ماه‌ها به‌شکلِ یک تستِ «flaky» ظاهر شد؛
باگِ واقعی بود، نه تصادفِ زمان‌بندی. به‌جایش نتیجه را با `list(...)` مادی کنید.
کرسرِ خواندن در تمامِ طولِ حلقه باز می‌ماند و قفل و اتصال را تا پایانِ حلقه نگه می‌دارد؛ روی SQLite هیچ retryای
نجاتش نمی‌دهد. این یک بارْ ماه‌ها به‌شکلِ یک تستِ «flaky» ظاهر شد؛ باگِ واقعی بود، نه تصادفِ زمان‌بندی. به‌جایش
نتیجه را با `list(...)` مادی کنید.
## لاگِ ساختاریافته