bring حسابان 11 and 12 into the game path

Both books were already complete on Qdrant and answering /api/ai/ask, but
had no Grade/Unit/Node/Lesson rows, so none of their problems could ever
reach a quiz. The owner made the product call, so the curriculum is now
seeded from the books' own tables of contents (free, no API credit), and
559 four-option questions were authored from the verified chunks.

Coverage: all 21 nodes of grade 11 hold a full four-question quiz, and 10
of 12 in grade 12. The two that do not are both in فصل ۵: کاربردهای مشتق
and are short only because the weekly model limit landed mid-run — their
(lesson, problem) pairs are already exported and waiting.

Three supporting changes:

- generate_questions_from_chunks grows --export DIR, which writes the
  (lesson, verified problem) pairs as JSON batches and makes no paid call
  at all. The subagent route was the documented way to author questions
  but had no tool behind it; now it does, and it emits the same PROMPT
  the paid path uses so the two branches cannot drift apart in quality.

- _targets now skips as many chunks as a lesson already has questions
  for. Re-running with a higher --per-lesson used to hand back the very
  same chunk, producing a near-duplicate question — silently, because the
  prompt gets rewritten each time, so neither the run nor question_io's
  validation could catch it.

- GRADE_STYLE gains یازدهم and دوازدهم. Without them both grades fell to
  the grey fallback; they are deliberately cool colours, separate from the
  six general grades, because حسابان is a ریاضی‌فیزیک-only book.

458 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 03:35:26 +03:30
parent 3026df0397
commit 82edd0ecb0
5 changed files with 12690 additions and 4 deletions

View File

@@ -386,3 +386,79 @@ Still open, and deliberately not acted on:
The frontend's `Question` type carries a `type` field that nothing reads, and The frontend's `Question` type carries a `type` field that nothing reads, and
`count_objects` in `Question.TYPE_CHOICES` is an empty shell — no rows, no serializer `count_objects` in `Question.TYPE_CHOICES` is an empty shell — no rows, no serializer
branch, no rendering anywhere. Do not build content for it without asking first. branch, no rendering anywhere. Do not build content for it without asking first.
## وضعیت ۱۴۰۵/۰۶/۲۹ (۲۰۲۶-۰۹-۲۰) — این را اول بخوان
سرور روی `3026df0` است، کد و محتوا هر دو منتشر شدند، سرویس واقعاً ری‌استارت شد، و
`/api/health` عدد `questions: 1783` می‌دهد. `/api/nodes/1/quiz` چهار سوالِ واقعی برمی‌گرداند.
**۵ کتاب روی Qdrant، همه با دیتابیس همگام:** ریاضی هشتم (۵۱۸)، نهم (۴۵۰)، دهم (۴۵۸)،
حسابان یازدهم (۳۷۶)، حسابان دوازدهم (۳۲۶). صفحه‌ی گم‌شده: صفر. سه کتابِ تیمِ دیگر
(سوم/ششم/هفتم، مجموعاً ۱٬۲۵۲) عمداً دست‌نخورده ماندند.
خرجِ کلِ آن روز ۳٫۴۴ دلار بود. روشِ ارزان و دقیقِ استخراج در `docs/extraction-playbook.md`
نوشته شده — **قبل از هر ingest تازه آن را بخوان**، مخصوصاً بخشِ انتخابِ مدل.
### حسابان یازدهم و دوازدهم: وارد مسیرِ بازی شد (۲۰۲۶-۰۹-۲۱)
کاربر تصمیمِ محصولی را گرفت و هر دو پایه ساخته شدند. `seed_curriculum_from_textbook`
اسکلت را رایگان ساخت — یازدهم ۵ فصل/۲۱ نود/۳۶۹ درس، دوازدهم ۵ فصل/۱۲ نود/۳۲۰ درس — و
سوال‌ها با subagentهای Sonnet نوشته شدند: **۵۵۹ سوال** (یازدهم ۳۲۲، دوازدهم ۲۳۷).
هر ۲۱ نودِ یازدهم آزمونِ کاملِ چهارسوالی دارند؛ از دوازدهم ۱۰ نود از ۱۲.
⚠️ **دو نودِ باقی‌مانده، هر دو در «فصل ۵: کاربردهای مشتق»** («جهت تقعر نمودار…» و
«رسم نمودار تابع»): سوال ندارند چون سقفِ هفتگیِ Sonnet وسطِ کار پر شد، نه چون داده کم
است. جفت‌هایشان آماده‌اند — دو دسته‌ی آخرِ همان اجرای `--export`.
`GRADE_STYLE` حالا برای یازدهم/دوازدهم رنگ دارد (فیروزه‌ای و نیلی، عمداً جدا از شش
پایه‌ی عمومی چون حسابان فقط کتابِ رشته‌ی ریاضی‌فیزیک است).
### روالِ ساختِ سوال با subagent، حالا ابزار دارد
`generate_questions_from_chunks --export DIR` جفت‌های (درس، مسئله‌ی تأییدشده) را در
فایل‌های JSON می‌ریزد و **هیچ فراخوانیِ پولی نمی‌کند**؛ کنارشان یک `*-RULES.txt` با
عینِ همان PROMPTی که به مدلِ پولی می‌رفت می‌نویسد، تا کیفیتِ دو شاخه از هم جدا نیفتد.
خروجیِ subagent با `content.question_io` وارد می‌شود (فایلِ بد هیچ‌چیز نمی‌نویسد).
بازده روی ۶۳۰ ورودی: **۵۵۹ سوال، ۸۹٪** — بقیه عمداً رد شدند، بیشترشان مسئله‌هایی که
بدونِ شکلِ کتاب خودکفا نمی‌شوند. چند subagent خطایِ حسابِ خودِ کتاب را هم گرفتند
(راه‌حلی که √ را ۶ نوشته بود ولی ۶٫۵ بود؛ راه‌حلی که می‌گفت حد وجود ندارد درحالی‌که
داشت) — یعنی `solution_verdict='agrees'` تضمینِ کامل نیست، فقط فیلترِ اول است.
**باگی که همان‌جا پیدا و اصلاح شد:** اجرای دوم با `--per-lesson` بالاتر همان chunkِ
قبلی را دوباره تحویل می‌داد و سوالِ تقریباً تکراری می‌ساخت. حالا `_targets` به اندازه‌ی
سوال‌هایی که درس از قبل دارد از ترتیبِ قطعیِ chunkها می‌پرد. این خطا نه لاگ می‌داد، نه
در import گیر می‌افتاد (چون صورتِ سوال بازنویسی می‌شود و رشته‌اش یکی نیست).
⚠️ **سقفِ هفتگیِ Sonnet واقعی است و ناگهانی می‌رسد.** ۲۰ subagentِ هم‌زمان (هرکدام
~۹۵ هزار توکن برای ۳۰ سوال) آن را در یک نشست پر کرد. اگر کارِ انبوه داری، دسته‌ها را
کوچک‌تر بگیر و بینشان نتیجه را وارد کن — خوشبختانه بیشترِ agentها فایلشان را نوشته
بودند و فقط در تماسِ آخر خوردند، پس چیزی از دست نرفت.
### چیزهایی که آن روز کشف شد و تکرارشان گران است
- **مدل را اندازه بگیر، از جدولِ قیمت نخوان.** قیمتِ GapGPT با قیمتِ رسمی یکی نیست:
`gpt-4.1-mini` هم از `gpt-4o` ارزان‌تر بود هم دقیق‌تر، و `gemini-3.6-flash` برخلافِ
انتظار سه برابرِ `gpt-4o` گران درآمد. `OPENAI_VISION_MODEL=gpt-4.1-mini` الان در `.env`
است و هر پنج کتاب با همان خوانده شد.
- **ساختِ سوال را با subagent انجام بده، نه با API.** `generate_questions_from_chunks`
پیش‌فرضش `o4-mini` است و ~۰٫۷۷ سنت برای هر سوال می‌گیرد (چهار برابرِ تخمینِ خودش، چون
مدلِ ریزنینگ است). همان کار با subagentهای Sonnet رایگان است و بازده‌اش ۹۲٪ بود.
روالش: جفت‌های (درس، مسئله‌ی تأییدشده) را به JSON بده، subagent خروجی بنویسد، و با
`content.question_io` واردش کن.
- **`--recheck` در `rag_verify_solutions` همه‌چیز را دوباره می‌فرستد**، نه فقط موارد تازه.
برای بازبینیِ یک زیرمجموعه، رأیشان را خالی کن و اجرای عادی بزن.
- **شمارنده‌ی صفحات حینِ `--refresh` معنی ندارد** — هر صفحه اول پاک و بعد خوانده می‌شود،
پس عدد دورِ کل نوسان می‌کند. پیشرفت را از `IngestedPage.created_at` بسنج.
- **در `until`ِ انتظار، `ps | grep` خودش را می‌شمارد** و حلقه هرگز تمام نمی‌شود. از
`pgrep -f` استفاده کن.
- **کد و محتوا دو مسیرِ جدا دارند.** `db.sqlite3` در gitignore است؛ سوال‌ها فقط از راهِ
`seed_content --export --only questions` و کامیتِ `content/seed/questions.json` به سرور
می‌رسند. پوش کردنِ کد به‌تنهایی محتوا را نمی‌برد.
### CORS
پورت‌های توسعه دیگر شمرده نمی‌شوند؛ `CORS_ALLOWED_ORIGIN_REGEXES` هر پورتی روی
`localhost`/`127.0.0.1` را می‌پذیرد. اگر باز هم گزارشِ CORS رسید، اول بپرس **Hard Refresh**
زده‌اند یا نه — مرورگر نتیجه‌ی preflight را کش می‌کند.

View File

@@ -13,8 +13,16 @@
python manage.py generate_questions_from_chunks --grade نهم --limit 5 --dry-run python manage.py generate_questions_from_chunks --grade نهم --limit 5 --dry-run
python manage.py generate_questions_from_chunks --grade نهم --limit 20 python manage.py generate_questions_from_chunks --grade نهم --limit 20
python manage.py generate_questions_from_chunks --grade نهم python manage.py generate_questions_from_chunks --grade نهم
مسیرِ رایگان (و پیش‌فرضِ عملی): `--export DIR` هیچ فراخوانیِ پولی نمی‌کند و
فقط جفت‌های (درس، مسئله‌ی تأییدشده) را در چند فایلِ JSON می‌ریزد تا subagentها
سوال‌ها را بنویسند و خروجی با `content.question_io` وارد شود. اندازه‌گیریِ
۱۴۰۵/۰۶/۲۹: o4-mini روی این پروکسی ۰٫۷۷ سنت برای هر سوال، subagentِ Sonnet
صفر — با بازده‌ی ۹۲٪. متنِ پرامپت برای هر دو مسیر یکی است (همین PROMPT پایین)،
پس کیفیت دو شاخه از هم جدا نمی‌افتد.
""" """
import json import json
import os
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -92,6 +100,11 @@ class Command(BaseCommand):
help='چند سوال برای هر درس.') help='چند سوال برای هر درس.')
parser.add_argument('--dry-run', action='store_true', parser.add_argument('--dry-run', action='store_true',
help='فقط نشان بده چند سوال ساخته می‌شود؛ هیچ فراخوانیِ پولی نکن.') help='فقط نشان بده چند سوال ساخته می‌شود؛ هیچ فراخوانیِ پولی نکن.')
parser.add_argument('--export', metavar='DIR',
help='به‌جای فراخوانیِ مدل، جفت‌های (درس، مسئله) را در این '
'پوشه به JSON بنویس تا subagent سوال‌ها را بسازد. رایگان.')
parser.add_argument('--batch-size', type=int, default=30,
help='چند جفت در هر فایلِ --export. پیش‌فرض ۳۰.')
parser.add_argument('--model', default='o4-mini', parser.add_argument('--model', default='o4-mini',
help='مدلِ سازنده. پیش‌فرض o4-mini؛ gpt-4o-mini کیفیتِ ' help='مدلِ سازنده. پیش‌فرض o4-mini؛ gpt-4o-mini کیفیتِ '
'قابلِ قبولی نداد (۴ سوال از ۶ خراب).') 'قابلِ قبولی نداد (۴ سوال از ۶ خراب).')
@@ -135,6 +148,10 @@ class Command(BaseCommand):
if len(targets) > 10: if len(targets) > 10:
self.stdout.write(f' … و {len(targets) - 10} مورد دیگر') self.stdout.write(f' … و {len(targets) - 10} مورد دیگر')
if opts['export']:
self._export(targets, opts)
return
# ~۹۰۰ توکن ورودی و ~۱۵۰ خروجی برای هر سوال با gpt-4o-mini. # ~۹۰۰ توکن ورودی و ~۱۵۰ خروجی برای هر سوال با gpt-4o-mini.
self.model = opts['model'] self.model = opts['model']
cost = len(targets) * 0.0018 # اندازه‌گیریِ واقعیِ o4-mini cost = len(targets) * 0.0018 # اندازه‌گیریِ واقعیِ o4-mini
@@ -196,6 +213,58 @@ class Command(BaseCommand):
f'\n{created} سوال ساخته شد، {skipped} رد شد، {failed} خطا.' f'\n{created} سوال ساخته شد، {skipped} رد شد، {failed} خطا.'
)) ))
# ------------------------------------------------------------------
def _export(self, targets, opts):
"""
جفت‌ها را در فایل‌های کوچکِ JSON بنویس؛ هیچ مدلی صدا زده نمی‌شود.
چرا تکه‌تکه و نه یک فایلِ بزرگ: هر فایل سهمِ یک subagent است و باید در
یک نشست جا شود. و چرا `prompt` را هم کنارش می‌نویسیم: تا کسی که
سوال‌ها را می‌سازد قواعد را از حافظه بازنویسی نکند — همان متنی که به
مدلِ پولی می‌رفت عیناً در دسترسِ subagent است.
"""
directory = opts['export']
os.makedirs(directory, exist_ok=True)
book = self._book
size = max(1, opts['batch_size'])
slug = f'{book.subject}-{book.grade}'
written = []
for index in range(0, len(targets), size):
rows = [
{
'lessonId': lesson.pk,
'unit': lesson.node.unit.title,
'node': lesson.node.title,
'lessonTitle': lesson.title,
'problemType': chunk.problem_type,
'problem': chunk.problem,
'solution': chunk.solution,
}
for lesson, chunk in targets[index:index + size]
]
path = os.path.join(directory, f'{slug}-{index // size + 1:02d}.json')
with open(path, 'w', encoding='utf-8') as handle:
json.dump(rows, handle, ensure_ascii=False, indent=1)
written.append((path, len(rows)))
rules = os.path.join(directory, f'{slug}-RULES.txt')
with open(rules, 'w', encoding='utf-8') as handle:
handle.write(PROMPT.format(
grade=book.grade, problem_type='<problemType>',
problem='<problem>', solution='<solution>',
))
self.stdout.write(self.style.SUCCESS(
f'\n{len(targets)} جفت در {len(written)} فایل نوشته شد: {directory}'
))
self.stdout.write(f' قواعدِ ساختِ سوال: {rules}')
self.stdout.write(
' خروجیِ هر subagent باید قالبِ content.question_io باشد '
'({"lessonId", "prompt", "choices", "correct"}) و با '
'«ورودِ سوال» در پنلِ ادمین یا import_questions وارد شود.'
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _page_span(self, lesson): def _page_span(self, lesson):
"""بازه‌ی صفحه‌ی PDF برای نودِ این درس، از فهرستِ خودِ کتاب.""" """بازه‌ی صفحه‌ی PDF برای نودِ این درس، از فهرستِ خودِ کتاب."""
@@ -214,7 +283,7 @@ class Command(BaseCommand):
return book.pdf_pages(chapter) return book.pdf_pages(chapter)
return None return None
def _chunks_by_page(self, lesson, collection, need, opts, used): def _chunks_by_page(self, lesson, collection, need, opts, used, skip=0):
span = self._page_span(lesson) span = self._page_span(lesson)
if span is None: if span is None:
return [] return []
@@ -224,7 +293,7 @@ class Command(BaseCommand):
.exclude(problem='').exclude(pk__in=used)) .exclude(problem='').exclude(pk__in=used))
if opts['verified_only']: if opts['verified_only']:
qs = qs.filter(solution_verdict=LessonChunk.VERDICT_AGREES) qs = qs.filter(solution_verdict=LessonChunk.VERDICT_AGREES)
return list(qs.order_by('source_page', 'order')[:need]) return list(qs.order_by('source_page', 'order')[skip:skip + need])
def _targets(self, grade, collection, opts): def _targets(self, grade, collection, opts):
"""[(lesson, chunk), ...] — فقط درس‌هایی که هنوز سوال کم دارند.""" """[(lesson, chunk), ...] — فقط درس‌هایی که هنوز سوال کم دارند."""
@@ -252,7 +321,12 @@ class Command(BaseCommand):
if opts['verified_only']: if opts['verified_only']:
candidates = candidates.filter( candidates = candidates.filter(
solution_verdict=LessonChunk.VERDICT_AGREES) solution_verdict=LessonChunk.VERDICT_AGREES)
chunks = list(candidates.order_by('source_page', 'order')[:need]) # از `have` تا به بعد، نه از اول: ترتیب قطعی است، پس اجرای قبلی
# دقیقاً `have` تکه‌ی نخست را مصرف کرده. بدونِ این پرش، هر اجرای
# دوم با --per-lesson بالاتر همان مسئله را دوباره تحویل می‌داد و
# سوالِ تقریباً تکراری می‌ساخت — که نه خطا می‌دهد و نه در import
# گیر می‌افتد، چون صورتِ سوال بازنویسی شده و رشته‌اش یکی نیست.
chunks = list(candidates.order_by('source_page', 'order')[have:have + need])
# تطبیقِ عنوان شکننده است: عنوانِ درس از یک استخراجِ قدیمی آمده و # تطبیقِ عنوان شکننده است: عنوانِ درس از یک استخراجِ قدیمی آمده و
# هر بار که کتاب دوباره خوانده شود مدل عنوانِ تازه می‌سازد. بعد از # هر بار که کتاب دوباره خوانده شود مدل عنوانِ تازه می‌سازد. بعد از
@@ -264,7 +338,8 @@ class Command(BaseCommand):
# chunkهای همان بازه به درس‌های همان نود می‌خورند — بدونِ هیچ # chunkهای همان بازه به درس‌های همان نود می‌خورند — بدونِ هیچ
# فراخوانیِ مدلی. # فراخوانیِ مدلی.
if not chunks and opts['by_page']: if not chunks and opts['by_page']:
chunks = self._chunks_by_page(lesson, collection, need, opts, used) chunks = self._chunks_by_page(lesson, collection, need, opts, used,
skip=have)
for chunk in chunks: for chunk in chunks:
used.add(chunk.pk) used.add(chunk.pk)

View File

@@ -32,6 +32,10 @@ GRADE_STYLE = {
'هشتم': ('grade-8', '#9C27B0'), 'هشتم': ('grade-8', '#9C27B0'),
'نهم': ('grade-9', '#FF9800'), 'نهم': ('grade-9', '#FF9800'),
'دهم': ('grade-10', '#E91E63'), 'دهم': ('grade-10', '#E91E63'),
# حسابان: رشته‌ی ریاضی‌فیزیک، پس عمداً رنگِ سرد و جدا از شش پایه‌ی عمومی
# تا در نقشه‌ی بازی به‌چشم یک مسیرِ دیگر بیاید، نه ادامه‌ی همان‌ها.
'یازدهم': ('grade-11', '#00BCD4'),
'دوازدهم': ('grade-12', '#3F51B5'),
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff