Figure questions for grade-10 geometry (115 new), quiz prefers a figured question per lesson, tools/figures/batch.py
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -260,3 +260,32 @@ class NodeQuizStabilityTests(TestCase):
|
|||||||
UserNodeCompletion.objects.create(
|
UserNodeCompletion.objects.create(
|
||||||
user_id=self.user.id, node=self.node, correct_count=4, total_questions=4, stars=3)
|
user_id=self.user.id, node=self.node, correct_count=4, total_questions=4, stars=3)
|
||||||
self.assertEqual(first, self._ids())
|
self.assertEqual(first, self._ids())
|
||||||
|
|
||||||
|
|
||||||
|
class NodeQuizPrefersFigureTests(TestCase):
|
||||||
|
"""از میانِ سوالهای یک درس، سوالِ شکلدار انتخاب میشود و آزمون برای کاربر ثابت میماند."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
grade = Grade.objects.create(title='پایه دهم', icon='', color='')
|
||||||
|
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||||
|
self.node = Node.objects.create(unit=unit, title='اول', order=1)
|
||||||
|
self.figured = []
|
||||||
|
for i in range(3):
|
||||||
|
lesson = Lesson.objects.create(node=self.node, title=f'درس {i}')
|
||||||
|
for j in range(5):
|
||||||
|
figure = {'kind': 'bar', 'title': 't', 'labels': ['a'], 'values': [1]} if j == 3 else None
|
||||||
|
q = Question.objects.create(lesson=lesson, type='multiple_choice', prompt=f'س {i}-{j}', figure=figure)
|
||||||
|
Choice.objects.create(question=q, value='الف', is_correct=True)
|
||||||
|
Choice.objects.create(question=q, value='ب', is_correct=False)
|
||||||
|
if figure:
|
||||||
|
self.figured.append(q.id)
|
||||||
|
self.user = User.objects.create(phone='09120000020', grade_id=grade.id)
|
||||||
|
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
|
||||||
|
|
||||||
|
def test_figured_question_wins_in_every_lesson_and_is_stable(self):
|
||||||
|
ids = lambda: [q['id'] for q in self.client.get(
|
||||||
|
f'/api/nodes/{self.node.pk}/quiz?size=3', **self.auth).json()['questions']]
|
||||||
|
first = ids()
|
||||||
|
self.assertCountEqual(first, [str(i) for i in self.figured])
|
||||||
|
self.assertEqual(first, ids())
|
||||||
|
|||||||
@@ -1117,10 +1117,17 @@ class NodeQuizView(APIView):
|
|||||||
|
|
||||||
# حداکثر یک سوال از هر درس تا آزمون روی یک مهارت گیر نکند؛ اگر با این
|
# حداکثر یک سوال از هر درس تا آزمون روی یک مهارت گیر نکند؛ اگر با این
|
||||||
# شرط به اندازهی لازم نرسیدیم، بقیه از همان استخر پر میشوند.
|
# شرط به اندازهی لازم نرسیدیم، بقیه از همان استخر پر میشوند.
|
||||||
|
# از میانِ سوالهای یک درس، سوالِ شکلدار مقدم است (تصمیمِ مالک ۲۰۲۶-۱۰-۱۴: هندسه و آمار
|
||||||
|
# بدونِ شکل کماثر است). ترتیبِ درهمریختهی پایدار حفظ میشود، پس برای هر کاربر ثابت میماند.
|
||||||
|
best = {}
|
||||||
|
for question in questions:
|
||||||
|
if question.lesson_id not in best or (best[question.lesson_id].figure is None and question.figure is not None):
|
||||||
|
best[question.lesson_id] = question
|
||||||
picked, seen_lessons = [], set()
|
picked, seen_lessons = [], set()
|
||||||
for question in questions:
|
for question in questions:
|
||||||
if question.lesson_id in seen_lessons:
|
if question.lesson_id in seen_lessons:
|
||||||
continue
|
continue
|
||||||
|
question = best[question.lesson_id]
|
||||||
seen_lessons.add(question.lesson_id)
|
seen_lessons.add(question.lesson_id)
|
||||||
picked.append(question)
|
picked.append(question)
|
||||||
if len(picked) >= size:
|
if len(picked) >= size:
|
||||||
|
|||||||
129
tools/figures/batch.py
Normal file
129
tools/figures/batch.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
ابزارِ سوالِ شکلدار: `export` درسهای یک فصل را برای نویسنده بیرون میدهد، `check` خروجیِ او را
|
||||||
|
سختگیرانه میسنجد (validate بیصدا آیتمِ خراب را حذف میکند؛ اینجا حذف یعنی رد).
|
||||||
|
|
||||||
|
python tools/figures/batch.py spec # کاتالوگِ کاملِ نوعهای شکل
|
||||||
|
python tools/figures/batch.py export "پایه دهم" "فصل ۱" هندسه [--out f.json]
|
||||||
|
python tools/figures/batch.py check batch.json # بدونِ نوشتن در seed
|
||||||
|
python tools/figures/batch.py merge batch.json # افزودن به content/seed/questions.json
|
||||||
|
|
||||||
|
آزموده نشده: درستیِ جوابِ سوالها را این ابزار نمیسنجد؛ آن را re-solveِ مستقل میسنجد.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
import django # noqa: E402
|
||||||
|
|
||||||
|
django.setup()
|
||||||
|
from rag.services import diagram # noqa: E402
|
||||||
|
|
||||||
|
SEED = os.path.join(os.path.dirname(__file__), '..', '..', 'content', 'seed')
|
||||||
|
|
||||||
|
|
||||||
|
def _curriculum():
|
||||||
|
return json.load(open(os.path.join(SEED, 'curriculum.json')))['grades']
|
||||||
|
|
||||||
|
|
||||||
|
def _questions():
|
||||||
|
return json.load(open(os.path.join(SEED, 'questions.json')))['questions']
|
||||||
|
|
||||||
|
|
||||||
|
def export(grade, unit_prefix, subject=None):
|
||||||
|
qs = _questions()
|
||||||
|
rows = []
|
||||||
|
for g in _curriculum():
|
||||||
|
if g['title'] != grade:
|
||||||
|
continue
|
||||||
|
for u in g['units']:
|
||||||
|
if not u['title'].startswith(unit_prefix + ':') or (subject and u['subject'] != subject):
|
||||||
|
continue
|
||||||
|
for n in u['nodes']:
|
||||||
|
for l in n['lessons']:
|
||||||
|
path = [g['title'], u['title'], n['title'], l['title']]
|
||||||
|
have = [q for q in qs if q['path'] == path]
|
||||||
|
rows.append({
|
||||||
|
'path': path, 'subject': u['subject'], 'intro': l.get('introText', '')[:400],
|
||||||
|
'figuredAlready': sum(1 for q in have if q.get('figure')),
|
||||||
|
'existingPrompts': [q['prompt'][:110] for q in have[:3]],
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _strict(row, i):
|
||||||
|
errs = []
|
||||||
|
path = row.get('path')
|
||||||
|
if not (isinstance(path, list) and len(path) == 4):
|
||||||
|
errs.append('path باید چهار عنوان باشد')
|
||||||
|
ch = row.get('choices')
|
||||||
|
if not (isinstance(ch, list) and len(ch) == 4 and len(set(map(str, ch))) == 4):
|
||||||
|
errs.append('choices باید چهار گزینهی متفاوت باشد')
|
||||||
|
if row.get('correct') not in (0, 1, 2, 3):
|
||||||
|
errs.append('correct باید ۰ تا ۳ باشد')
|
||||||
|
if row.get('type') != 'multiple_choice':
|
||||||
|
errs.append('type باید multiple_choice باشد')
|
||||||
|
fig = row.get('figure')
|
||||||
|
if not fig:
|
||||||
|
errs.append('figure خالی است')
|
||||||
|
else:
|
||||||
|
clean = diagram.validate(fig)
|
||||||
|
if clean is None:
|
||||||
|
errs.append('figure نامعتبر')
|
||||||
|
elif json.dumps(clean, sort_keys=True) != json.dumps(diagram.validate(clean), sort_keys=True):
|
||||||
|
errs.append('figure پایدار نیست')
|
||||||
|
else:
|
||||||
|
n_in = len(fig.get('items') or []) + len(fig.get('points') or {})
|
||||||
|
n_out = len(clean.get('items') or []) + len(clean.get('points') or {})
|
||||||
|
if n_out < n_in:
|
||||||
|
errs.append(f'validate {n_in - n_out} آیتم/نقطه را حذف کرد')
|
||||||
|
p = row.get('prompt') or ''
|
||||||
|
if any(t in p for t in ('$', '\\frac', '\\(', '**')):
|
||||||
|
errs.append('LaTeX/markdown در prompt')
|
||||||
|
return [f'ردیف {i}: {e}' for e in errs]
|
||||||
|
|
||||||
|
|
||||||
|
def check(path):
|
||||||
|
rows = json.load(open(path))
|
||||||
|
rows = rows['questions'] if isinstance(rows, dict) else rows
|
||||||
|
curr = {tuple([g['title'], u['title'], n['title'], l['title']])
|
||||||
|
for g in _curriculum() for u in g['units'] for n in u['nodes'] for l in n['lessons']}
|
||||||
|
errs = []
|
||||||
|
for i, r in enumerate(rows):
|
||||||
|
errs += _strict(r, i)
|
||||||
|
if tuple(r.get('path') or ()) not in curr:
|
||||||
|
errs.append(f'ردیف {i}: درس در برنامهی درسی نیست')
|
||||||
|
print(f'{len(rows)} سوال، {len(errs)} خطا')
|
||||||
|
print('\n'.join(errs[:40]))
|
||||||
|
return not errs
|
||||||
|
|
||||||
|
|
||||||
|
def merge(path):
|
||||||
|
if not check(path):
|
||||||
|
sys.exit('خطا دارد؛ ادغام نشد')
|
||||||
|
rows = json.load(open(path))
|
||||||
|
rows = rows['questions'] if isinstance(rows, dict) else rows
|
||||||
|
p = os.path.join(SEED, 'questions.json')
|
||||||
|
data = json.load(open(p))
|
||||||
|
have = {(tuple(q['path']), q['prompt'].strip()) for q in data['questions']}
|
||||||
|
new = [r for r in rows if (tuple(r['path']), r['prompt'].strip()) not in have]
|
||||||
|
data['questions'] += new
|
||||||
|
json.dump(data, open(p, 'w'), ensure_ascii=False, indent=1)
|
||||||
|
print(f'{len(new)} سوال افزوده شد')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
if cmd == 'spec':
|
||||||
|
print(diagram.DIAGRAM_SPEC)
|
||||||
|
elif cmd == 'export':
|
||||||
|
out = json.dumps(export(sys.argv[2], sys.argv[3], sys.argv[4] if len(sys.argv) > 4 and not sys.argv[4].startswith('--') else None), ensure_ascii=False, indent=1)
|
||||||
|
if '--out' in sys.argv:
|
||||||
|
open(sys.argv[sys.argv.index('--out') + 1], 'w').write(out)
|
||||||
|
else:
|
||||||
|
print(out)
|
||||||
|
elif cmd == 'check':
|
||||||
|
sys.exit(0 if check(sys.argv[2]) else 1)
|
||||||
|
elif cmd == 'merge':
|
||||||
|
merge(sys.argv[2])
|
||||||
Reference in New Issue
Block a user