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:
@@ -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 20
|
||||
python manage.py generate_questions_from_chunks --grade نهم
|
||||
|
||||
مسیرِ رایگان (و پیشفرضِ عملی): `--export DIR` هیچ فراخوانیِ پولی نمیکند و
|
||||
فقط جفتهای (درس، مسئلهی تأییدشده) را در چند فایلِ JSON میریزد تا subagentها
|
||||
سوالها را بنویسند و خروجی با `content.question_io` وارد شود. اندازهگیریِ
|
||||
۱۴۰۵/۰۶/۲۹: o4-mini روی این پروکسی ۰٫۷۷ سنت برای هر سوال، subagentِ Sonnet
|
||||
صفر — با بازدهی ۹۲٪. متنِ پرامپت برای هر دو مسیر یکی است (همین PROMPT پایین)،
|
||||
پس کیفیت دو شاخه از هم جدا نمیافتد.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -92,6 +100,11 @@ class Command(BaseCommand):
|
||||
help='چند سوال برای هر درس.')
|
||||
parser.add_argument('--dry-run', action='store_true',
|
||||
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',
|
||||
help='مدلِ سازنده. پیشفرض o4-mini؛ gpt-4o-mini کیفیتِ '
|
||||
'قابلِ قبولی نداد (۴ سوال از ۶ خراب).')
|
||||
@@ -135,6 +148,10 @@ class Command(BaseCommand):
|
||||
if len(targets) > 10:
|
||||
self.stdout.write(f' … و {len(targets) - 10} مورد دیگر')
|
||||
|
||||
if opts['export']:
|
||||
self._export(targets, opts)
|
||||
return
|
||||
|
||||
# ~۹۰۰ توکن ورودی و ~۱۵۰ خروجی برای هر سوال با gpt-4o-mini.
|
||||
self.model = opts['model']
|
||||
cost = len(targets) * 0.0018 # اندازهگیریِ واقعیِ o4-mini
|
||||
@@ -196,6 +213,58 @@ class Command(BaseCommand):
|
||||
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):
|
||||
"""بازهی صفحهی PDF برای نودِ این درس، از فهرستِ خودِ کتاب."""
|
||||
@@ -214,7 +283,7 @@ class Command(BaseCommand):
|
||||
return book.pdf_pages(chapter)
|
||||
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)
|
||||
if span is None:
|
||||
return []
|
||||
@@ -224,7 +293,7 @@ class Command(BaseCommand):
|
||||
.exclude(problem='').exclude(pk__in=used))
|
||||
if opts['verified_only']:
|
||||
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):
|
||||
"""[(lesson, chunk), ...] — فقط درسهایی که هنوز سوال کم دارند."""
|
||||
@@ -252,7 +321,12 @@ class Command(BaseCommand):
|
||||
if opts['verified_only']:
|
||||
candidates = candidates.filter(
|
||||
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های همان بازه به درسهای همان نود میخورند — بدونِ هیچ
|
||||
# فراخوانیِ مدلی.
|
||||
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:
|
||||
used.add(chunk.pk)
|
||||
|
||||
@@ -32,6 +32,10 @@ GRADE_STYLE = {
|
||||
'هشتم': ('grade-8', '#9C27B0'),
|
||||
'نهم': ('grade-9', '#FF9800'),
|
||||
'دهم': ('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
Reference in New Issue
Block a user