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:
@@ -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(...)` مادی کنید.
|
||||
|
||||
## لاگِ ساختاریافته
|
||||
|
||||
|
||||
Reference in New Issue
Block a user