complete the quiz path for grades 8 through 12, and close three gaps in the API contract

Content
-------
Every node in grades 8, 9, 10, 11 and 12 now holds a full four-question
quiz — 121 more questions, filling the two حسابان دوازدهم nodes in
فصل ۵: کاربردهای مشتق that were empty and the two ریاضی هشتم nodes that
were short. The repo's own seed guard was already failing on those two
empty nodes; it passes now.

Grades 3, 6 and 7 are the other team's books and were left alone. 39 of
their 145 nodes still hold fewer than four questions, but none is empty,
so every node on the map still plays.

An audit over all 1,593 questions in grades 8–12 found no structural
fault: no question with the wrong number of options, no question with
zero or two correct answers, no empty or duplicated option, no leftover
LaTeX, and no two identical questions inside one lesson.

Contract
--------
Three endpoints were missing or malformed in the OpenAPI schema, which
matters more than it looks: that schema is the contract the frontend
generates its client from, so a view missing there is a view the other
repo cannot call.

- NotificationMarkReadView was dropped from the schema entirely, because
  spectacular could not guess a request body for its PATCH and gives up
  on the whole view rather than the one field. It declares request=None.
- PaymentRedirectView was dropped for the same reason; it answers with a
  302 and no JSON body, which it now says.
- /api/health and /api/health/ collided on operationId and were emitted
  as health_retrieve and health_retrieve_2. The slash variant exists only
  for a monitor that calls it that way, so it is excluded from the schema.

`spectacular` now runs clean — no errors, no warnings.

Tidying
-------
Condensed the two longest comment blocks (18 and 14 lines) in
rag_verify_solutions and settings, keeping the measurement and the rule
and dropping the retelling. README gains the bulk question-authoring
workflow; the playbook's subagent section now points at the real tool
instead of describing the steps by hand.

458 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 03:49:32 +03:30
parent 82edd0ecb0
commit 6a6d0200d2
9 changed files with 2140 additions and 41 deletions

View File

@@ -170,8 +170,8 @@ actually connected without asking a human.
## Content: where it lives and how it gets to a server ## Content: where it lives and how it gets to a server
`content/seed/` holds the whole curriculum and question bank as committed JSON — `content/seed/` holds the whole curriculum and question bank as committed JSON —
`curriculum.json` (6 grades, 48 units, 230 nodes, 1,928 lessons) and `questions.json` `curriculum.json` (8 grades, 58 units, 263 nodes, 2,617 lessons) and `questions.json`
(1,441 multiple-choice questions). `deploy.sh` runs `seed_content` on every release, so a (2,463 multiple-choice questions). `deploy.sh` runs `seed_content` on every release, so a
fresh server fills itself with one command: **no Qdrant, no AI credit, no SSH session.** fresh server fills itself with one command: **no Qdrant, no AI credit, no SSH session.**
`seed_content` is idempotent and never deletes. Questions bind to lessons **by title path** `seed_content` is idempotent and never deletes. Questions bind to lessons **by title path**
@@ -243,7 +243,9 @@ reliably pull the final answer out of a solution and produced ~40% broken output
- Throttle rates need periodic review against real traffic. - Throttle rates need periodic review against real traffic.
- `DEV_MASTER_OTP` (`0000`) must stay gated behind `DEBUG` — never enabled in prod. - `DEV_MASTER_OTP` (`0000`) must stay gated behind `DEBUG` — never enabled in prod.
- No video content exists yet, though `Lesson.kind`/`video_url` can express it. - No video content exists yet, though `Lesson.kind`/`video_url` can express it.
- 58 of 230 nodes still hold fewer than four questions; their verified chunks ran out. - Grades 8–12 are fully covered: every node holds a four-question quiz. 39 of the 145
nodes in grades 3/6/7 (the other team's books) still hold fewer than four; none is
empty, so every node still plays.
- Kavenegar is unconfigured, so `POST /api/auth/otp/request` returns 503. With `DEBUG=0` - Kavenegar is unconfigured, so `POST /api/auth/otp/request` returns 503. With `DEBUG=0`
there is no way to log into the API without creating an `OTPCode` row in the admin there is no way to log into the API without creating an `OTPCode` row in the admin
(`/admin/accounts/otpcode/add/`) — this is how to hand a tester a working login. (`/admin/accounts/otpcode/add/`) — this is how to hand a tester a working login.
@@ -404,11 +406,7 @@ branch, no rendering anywhere. Do not build content for it without asking first.
کاربر تصمیمِ محصولی را گرفت و هر دو پایه ساخته شدند. `seed_curriculum_from_textbook` کاربر تصمیمِ محصولی را گرفت و هر دو پایه ساخته شدند. `seed_curriculum_from_textbook`
اسکلت را رایگان ساخت — یازدهم ۵ فصل/۲۱ نود/۳۶۹ درس، دوازدهم ۵ فصل/۱۲ نود/۳۲۰ درس — و اسکلت را رایگان ساخت — یازدهم ۵ فصل/۲۱ نود/۳۶۹ درس، دوازدهم ۵ فصل/۱۲ نود/۳۲۰ درس — و
سوال‌ها با subagentهای Sonnet نوشته شدند: **۵۵۹ سوال** (یازدهم ۳۲۲، دوازدهم ۲۳۷). سوال‌ها با subagentهای Sonnet نوشته شدند: **۵۵۹ سوال** (یازدهم ۳۲۲، دوازدهم ۲۳۷).
هر ۲۱ نودِ یازدهم آزمونِ کاملِ چهارسوالی دارند؛ از دوازدهم ۱۰ نود از ۱۲. هر ۳۳ نودِ هر دو پایه آزمونِ کاملِ چهارسوالی دارند.
⚠️ **دو نودِ باقی‌مانده، هر دو در «فصل ۵: کاربردهای مشتق»** («جهت تقعر نمودار…» و
«رسم نمودار تابع»): سوال ندارند چون سقفِ هفتگیِ Sonnet وسطِ کار پر شد، نه چون داده کم
است. جفت‌هایشان آماده‌اند — دو دسته‌ی آخرِ همان اجرای `--export`.
`GRADE_STYLE` حالا برای یازدهم/دوازدهم رنگ دارد (فیروزه‌ای و نیلی، عمداً جدا از شش `GRADE_STYLE` حالا برای یازدهم/دوازدهم رنگ دارد (فیروزه‌ای و نیلی، عمداً جدا از شش
پایه‌ی عمومی چون حسابان فقط کتابِ رشته‌ی ریاضی‌فیزیک است). پایه‌ی عمومی چون حسابان فقط کتابِ رشته‌ی ریاضی‌فیزیک است).
@@ -420,7 +418,7 @@ branch, no rendering anywhere. Do not build content for it without asking first.
عینِ همان PROMPTی که به مدلِ پولی می‌رفت می‌نویسد، تا کیفیتِ دو شاخه از هم جدا نیفتد. عینِ همان PROMPTی که به مدلِ پولی می‌رفت می‌نویسد، تا کیفیتِ دو شاخه از هم جدا نیفتد.
خروجیِ subagent با `content.question_io` وارد می‌شود (فایلِ بد هیچ‌چیز نمی‌نویسد). خروجیِ subagent با `content.question_io` وارد می‌شود (فایلِ بد هیچ‌چیز نمی‌نویسد).
بازده روی ۶۳۰ ورودی: **۵۵۹ سوال، ۸۹٪** — بقیه عمداً رد شدند، بیشترشان مسئله‌هایی که بازده روی ۷۷۸ ورودی: **۶۸۰ سوال، ۸۷٪** — بقیه عمداً رد شدند، بیشترشان مسئله‌هایی که
بدونِ شکلِ کتاب خودکفا نمی‌شوند. چند subagent خطایِ حسابِ خودِ کتاب را هم گرفتند بدونِ شکلِ کتاب خودکفا نمی‌شوند. چند subagent خطایِ حسابِ خودِ کتاب را هم گرفتند
(راه‌حلی که √ را ۶ نوشته بود ولی ۶٫۵ بود؛ راه‌حلی که می‌گفت حد وجود ندارد درحالی‌که (راه‌حلی که √ را ۶ نوشته بود ولی ۶٫۵ بود؛ راه‌حلی که می‌گفت حد وجود ندارد درحالی‌که
داشت) — یعنی `solution_verdict='agrees'` تضمینِ کامل نیست، فقط فیلترِ اول است. داشت) — یعنی `solution_verdict='agrees'` تضمینِ کامل نیست، فقط فیلترِ اول است.

View File

@@ -170,6 +170,28 @@ curl https://<host>/api/health
- **برنامه‌ی درسی:** چیزی پاک نمی‌شود. هر چه در فایل هست ساخته/به‌روز می‌شود و - **برنامه‌ی درسی:** چیزی پاک نمی‌شود. هر چه در فایل هست ساخته/به‌روز می‌شود و
هر چه نیست دست‌نخورده می‌ماند، پس یک فایلِ ناقص محتوای سرور را از بین نمی‌برد. هر چه نیست دست‌نخورده می‌ماند، پس یک فایلِ ناقص محتوای سرور را از بین نمی‌برد.
### ساختِ انبوهِ سوال از کتاب
وقتی کتابی تازه روی Qdrant آمده و باید برایش سوال ساخته شود، مسیرِ رایگان این است:
```bash
# اسکلتِ برنامه‌ی درسی از فهرستِ خودِ کتاب (بدونِ هزینه)
python manage.py seed_curriculum_from_textbook --subject ریاضی --grade نهم --dry-run
python manage.py seed_curriculum_from_textbook --subject ریاضی --grade نهم
# جفت‌های (درس، مسئله‌ی تأییدشده) را برای سوال‌نویسی بیرون بده (بدونِ هزینه)
python manage.py generate_questions_from_chunks --subject ریاضی --grade نهم --export /tmp/batches
```
خروجی چند فایلِ JSON است به‌علاوه‌ی یک `*-RULES.txt` که قواعدِ ساختِ سوال را
دارد. سوال‌ها را با آن قواعد بنویسید و از «وارد کردنِ فله‌ای سوال» در جدولِ
بالا واردشان کنید. همان فرمان بدونِ `--export` کار را به مدلِ پولی می‌سپارد،
ولی گران‌تر است و کیفیتِ بهتری نمی‌دهد. جزئیات و هزینه‌های اندازه‌گیری‌شده در
`docs/extraction-playbook.md`.
`--dry-run` روی هر دو فرمان کار می‌کند و پیش از هر نوشتنی نشان می‌دهد چه ساخته
خواهد شد.
### داده‌ی تستی روی سرور ### داده‌ی تستی روی سرور
`seed_test_data` فقط برای محیطِ لوکال است و **نباید** روی سروری که کاربرِ واقعی `seed_test_data` فقط برای محیطِ لوکال است و **نباید** روی سروری که کاربرِ واقعی

View File

@@ -537,17 +537,13 @@ BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4) # Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4)
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# آدرسِ عمومیِ خودِ بک‌اند؛ هم برای ساختنِ paymentUrlی که به کلاینت برمی‌گردد # آدرسِ عمومیِ بک‌اند: هم paymentUrlی که به کلاینت برمی‌گردد از آن ساخته
# (PaymentRedirectView) و هم برای callback_url که به زرین‌پال می‌فرستیم لازم # می‌شود، هم callback_urlی که به زرین‌پال می‌رود.
# است.
# #
# چرا پیش‌فرض دارد: خالی بودنش سرویس را نمی‌خواباند، فقط مسیرِ پرداخت را # پیش‌فرض دارد چون خالی بودنش سرویس را نمی‌خواباند، فقط مسیرِ پرداخت را
# بی‌سروصدا می‌شکند — paymentUrl نسبی می‌شود و زرین‌پال callback خالی # بی‌صدا می‌شکند و تا اولین پرداختِ واقعی معلوم نمی‌شود. `.strip() or` هم
# می‌گیرد. این دقیقاً همان دسته خطاست که فقط موقعِ اولین پرداختِ واقعی معلوم # لازم است: کلید در .env سرور هست ولی خالی، و os.environ.get فقط وقتی کلید
# می‌شود. دامنه‌ی خودمان از قبل معلوم است، پس حدس زدنی نیست. # *نباشد* پیش‌فرض می‌دهد.
# .strip() اول لازم است: کلید در .env سرور وجود دارد ولی خالی است، و
# os.environ.get فقط وقتی کلید *نباشد* پیش‌فرض می‌دهد — مقدارِ خالی آن را
# خنثی می‌کرد و مسیرِ پرداخت باز هم بی‌صدا می‌شکست.
PUBLIC_BASE_URL = ( PUBLIC_BASE_URL = (
os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://api.math.tadrisino2.ir' os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://api.math.tadrisino2.ir'
).rstrip('/') ).rstrip('/')

View File

@@ -10,6 +10,8 @@ from django.contrib import admin
from django.http import JsonResponse from django.http import JsonResponse
from django.urls import include, path from django.urls import include, path
from django.views import defaults as django_default_views from django.views import defaults as django_default_views
from drf_spectacular.utils import extend_schema
from config.health import HealthView from config.health import HealthView
from drf_spectacular.views import ( from drf_spectacular.views import (
SpectacularAPIView, SpectacularAPIView,
@@ -50,7 +52,10 @@ urlpatterns = [
# هر `git pull` بعدی روی همین فایل گیر می‌کرد. یک مسیرِ اضافه ارزانش # هر `git pull` بعدی روی همین فایل گیر می‌کرد. یک مسیرِ اضافه ارزانش
# است در برابرِ یک فایلِ دست‌کاری‌شده روی پروداکشن. # است در برابرِ یک فایلِ دست‌کاری‌شده روی پروداکشن.
path('api/health', HealthView.as_view(), name='health'), path('api/health', HealthView.as_view(), name='health'),
path('api/health/', HealthView.as_view(), name='health-slash'), # همان ویو، فقط برای پایشی که با اسلش صدا می‌زند. از اسکیما بیرون است تا دو
# مسیرِ یکسان operationIdهای health_retrieve و health_retrieve_2 نسازند.
path('api/health/', extend_schema(exclude=True)(HealthView).as_view(),
name='health-slash'),
# OpenAPI schema + Swagger UI + Redoc # OpenAPI schema + Swagger UI + Redoc
path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/', SpectacularAPIView.as_view(), name='schema'),

File diff suppressed because it is too large Load Diff

View File

@@ -90,9 +90,31 @@ regexِ اولیه شاملِ `شکل|جدول|روبه‌رو|مقابل|زیر
روی ۳۴۵ ورودی **۹۲٪** بود. Haiku برای این کار مناسب نیست — جوابِ نهایی را روی ۳۴۵ ورودی **۹۲٪** بود. Haiku برای این کار مناسب نیست — جوابِ نهایی را
از راه‌حل بیرون نمی‌کشد. از راه‌حل بیرون نمی‌کشد.
مسیر: جفت‌های (درس، مسئله‌ی تأییدشده) را به JSON بیرون بدهید، subagent فایلِ مسیر حالا ابزار دارد و دستی نیست:
خروجی بنویسد، و از `content.question_io` واردش کنید — که اعتبارسنجی دارد و
فلسفه‌اش این است که **فایلِ بد هیچ‌چیز نمی‌نویسد**. ```bash
# ۱. جفت‌های (درس، مسئله‌ی تأییدشده) را بیرون بده — رایگان، هیچ فراخوانیِ پولی
python manage.py generate_questions_from_chunks \
--subject حسابان --grade یازدهم --export /tmp/batches
# ۲. برای هر فایلِ دسته یک subagentِ Sonnet بگذار که خروجی را در /tmp/out بنویسد
# ۳. وارد کن (اعتبارسنجی دارد؛ فایلِ بد هیچ‌چیز نمی‌نویسد)
# از پنلِ ادمین: /admin/content/question/ → «وارد کردنِ سوال‌ها از فایل»
# ۴. به فایلِ ریپو منتقل کن و کامیت کن
python manage.py seed_content --export --only questions
```
`--export` کنارِ دسته‌ها یک `*-RULES.txt` هم می‌نویسد که **عینِ همان PROMPTی است
که به مدلِ پولی می‌رفت**؛ پس کیفیتِ دو شاخه از هم جدا نمی‌افتد. اندازه‌ی دسته با
`--batch-size` تنظیم می‌شود (پیش‌فرض ۳۰).
⚠️ **سقفِ هفتگیِ Sonnet واقعی است و ناگهانی می‌رسد.** هر ۳۰ سوال حدودِ ۹۵ هزار
توکن می‌برد، پس ۲۰ subagentِ هم‌زمان ≈ ۲ میلیون توکن در یک نشست. قبل از شروع
حساب کنید؛ دسته‌ها را کوچک نگه دارید و بینشان نتیجه را وارد کنید.
**یک سوال بسازید، نه یک ترجمه.** subagentها چند بار خطای حسابِ خودِ کتاب را
گرفتند (راه‌حلی که ریشه را ۶ نوشته بود ولی ۶٫۵ بود؛ راه‌حلی که می‌گفت حد وجود
ندارد درحالی‌که داشت). یعنی `solution_verdict='agrees'` فیلترِ اول است، نه تضمین —
در پرامپت صریح بخواهید که جواب را مستقل حل کند و اگر نخواند، ردیف را رد کند.
--- ---

View File

@@ -48,7 +48,11 @@ class NotificationMarkReadView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@extend_schema(responses=MarkReadResponseSerializer, tags=['Notifications (notifications)']) # request=None لازم است: بدونِ آن spectacular نمی‌تواند بدنه‌ی PATCH را حدس
# بزند و **کلِ ویو را از اسکیما کنار می‌گذارد** — یعنی این مسیر در قراردادی
# که فرانت از آن کلاینت می‌سازد اصلاً دیده نمی‌شود.
@extend_schema(request=None, responses=MarkReadResponseSerializer,
tags=['Notifications (notifications)'])
def patch(self, request, id): def patch(self, request, id):
notification = get_object_or_404(Notification, pk=id, user_id=request.user.id) notification = get_object_or_404(Notification, pk=id, user_id=request.user.id)
if not notification.is_read: if not notification.is_read:

View File

@@ -191,6 +191,11 @@ class PaymentRedirectView(APIView):
throttle_scope = 'payment_pay' throttle_scope = 'payment_pay'
throttle_classes = [ScopedRateThrottle] throttle_classes = [ScopedRateThrottle]
@extend_schema(
responses={302: OpenApiTypes.NONE},
description='به درگاهِ زرین‌پال ریدایرکت می‌کند؛ بدنه‌ی JSON ندارد.',
tags=['Payment (payment)'],
)
def get(self, request, order_id): def get(self, request, order_id):
order = get_object_or_404(Order, pk=order_id, provider='gateway') order = get_object_or_404(Order, pk=order_id, provider='gateway')

View File

@@ -82,24 +82,14 @@ def is_account_limit(exc) -> bool:
return '403' in text and 'insufficient' in text return '403' in text and 'insufficient' in text
# مسئله‌ای که به شکل/جدولِ کتاب ارجاع می‌دهد و **محتوای آن را ننوشته** بدونِ # مسئله‌ای که به شکلِ کتاب ارجاع می‌دهد و محتوایش را ننوشته، بدونِ تصویر
# تصویر قابلِ حل نیست؛ فرستادنش هم پول می‌سوزاند هم آمار را خراب می‌کند. # قابلِ حل نیست؛ فرستادنش هم پول می‌سوزاند هم آمار را خراب می‌کند.
# #
# ⚠️ نسخه‌ی اولِ این محافظ یک regexِ تخت بود: # ⚠️ این محافظ باید دو شرطی بماند — ارجاعِ صریح به بیرونِ متن، *و* نیامدنِ
# شکل|جدول|نمودار|روبه‌رو|روبرو|مقابل|...|زیر را|بالا را # توصیف بعد از آن. نسخه‌ی اولش یک regexِ تخت روی «شکل|جدول|روبه‌رو|…» بود و
# و فاجعه‌بار گشاد بود. سه نمونه از چیزهایی که بی‌دلیل رد می‌کرد: # «زاویه‌های روبه‌رو در لوزی» یا «کسرهای زیر را گویا کنید: ۲/√۳» را هم می‌گرفت:
# «زاویه‌های روبه‌رو در لوزی برابرند؟» ← «روبه‌رو» یعنی مقابل، نه شکل # در نمونه‌ی ۵۰تایی ۸۴٪ از ردشده‌ها واقعاً قابلِ قضاوت بودند، یعنی ~۷۸۴ مسئله‌ی
# «کسرهای زیر را گویا کنید: ۲/√۳» ← داده همان‌جاست # سالم بی‌صدا از چرخه‌ی ساختِ سوال بیرون مانده بود.
# «به شکل مخروطی با شعاع ۴ و ارتفاع ۱۲» ← «به شکلِ ...» یعنی شبیهِ
# اندازه‌گیری (۱۴۰۵/۰۶/۲۹، نمونه‌ی ۵۰تایی از کتاب‌های بازخوانی‌شده): از
# مسئله‌هایی که این محافظ رد کرده بود، **۸۴٪ واقعاً قابلِ قضاوت بودند** —
# یعنی ~۷۸۴ مسئله‌ی سالم بی‌صدا از چرخه‌ی ساختِ سوال بیرون مانده بودند.
#
# قاعده‌ی تازه دو شرط دارد، و هر دو باید برقرار باشند:
# ۱. ارجاعِ صریح به چیزی بیرونِ متن باشد («شکل زیر»، «جدول روبه‌رو»)
# ۲. بعد از آن ارجاع، توصیفی نیامده باشد (نه «که ...»، نه عدد، نه نماد)
# شرطِ دوم همان چیزی است که قاعده‌ی self-contained تولید می‌کند: مدل حالا
# می‌نویسد «با توجه به شکل مقابل **که شامل {a,1} است**».
_FIGURE_REFERENCE = re.compile( _FIGURE_REFERENCE = re.compile(
r'(شکل|جدول|نمودار|تصویر|نقشه)\s*(ِ|های|‌های)?\s*' r'(شکل|جدول|نمودار|تصویر|نقشه)\s*(ِ|های|‌های)?\s*'
r'(زیر|بالا|روبه\u200cرو|روبرو|مقابل|فوق|داده\s*شده)' r'(زیر|بالا|روبه\u200cرو|روبرو|مقابل|فوق|داده\s*شده)'