Figures must not give the answer away: blank titles on new figured questions, 7 trivial questions removed, 7 reworded; apply_fixups tool

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 02:21:02 +03:30
parent 733796c25a
commit e6e983f970
4 changed files with 698 additions and 915 deletions

View File

@@ -330,6 +330,8 @@ has been done before.
time; this rule is the mechanism, so new figures need no other switch. Write them with time; this rule is the mechanism, so new figures need no other switch. Write them with
`tools/figures/batch.py` (`docs/content-authoring.md` «سوالِ شکل‌دار»). The lesson-level list `tools/figures/batch.py` (`docs/content-authoring.md` «سوالِ شکل‌دار»). The lesson-level list
(`/api/lessons/{id}/questions`) does not reorder; only the island quiz does. (`/api/lessons/{id}/questions`) does not reorder; only the island quiz does.
**A figure must not give the answer away** (title, labels, or drawing the thing the question asks for); the
rules and the fix-up tool (`tools/figures/apply_fixups.py`) are in `docs/content-authoring.md`.
- `POST /api/nodes/{id}/quiz/complete` completes the island at any score (score sets stars/XP) and returns - `POST /api/nodes/{id}/quiz/complete` completes the island at any score (score sets stars/XP) and returns
`nextNodeId`. It refuses a locked island (403 `node_locked`, same rule as `/api/home`) and a user who has `nextNodeId`. It refuses a locked island (403 `node_locked`, same rule as `/api/home`) and a user who has
not answered every question (409 `answers_missing`; required = `max(client totalQuestions, min(4, questions not answered every question (409 `answers_missing`; required = `max(client totalQuestions, min(4, questions

File diff suppressed because it is too large Load Diff

View File

@@ -161,3 +161,22 @@ python tools/figures/batch.py merge batch.json # افزود
چند شکل در یک صفحه، به `renderToString` با `identifierPrefix` یکتا برای هر شکل نیاز است، وگرنه `useId` در همه‌ی شکل‌ها چند شکل در یک صفحه، به `renderToString` با `identifierPrefix` یکتا برای هر شکل نیاز است، وگرنه `useId` در همه‌ی شکل‌ها
یکی می‌شود و clipPathها روی هم می‌افتند و منحنی‌ها نصفه دیده می‌شوند (خطای ابزارِ آزمایش، نه اپ). یکی می‌شود و clipPathها روی هم می‌افتند و منحنی‌ها نصفه دیده می‌شوند (خطای ابزارِ آزمایش، نه اپ).
فضای سه‌بعدی: `{"kind":"space",…}` با نقطه، بردار، پاره‌خط، چندضلعی (صفحه)، جعبه و خط‌چین‌های تصویر؛ تصویرِ مایلِ ثابت. فضای سه‌بعدی: `{"kind":"space",…}` با نقطه، بردار، پاره‌خط، چندضلعی (صفحه)، جعبه و خط‌چین‌های تصویر؛ تصویرِ مایلِ ثابت.
### شکل نباید جواب را لو بدهد
گزارشِ مالک (۲۰۲۶-۱۰-۱۵): سوالِ «نقطه‌ی A(۰، ۵، ۰) روی کدام محور است؟» شکلی داشت که نقطه را با همان مختصات روی محور
نشان می‌داد؛ شکل جواب را می‌داد. قاعده‌ها برای ساختنِ هر سوالِ شکل‌دار:
1. **متنِ شکل** (`title`، برچسب‌ها، `label`) جواب یا چیزی مساوی با آن نباشد. عنوان را خالی بگذارید یا کلی («شکل»)؛
عنوانِ «هرم با قاعده‌ی مربع» زیرِ سوالِ «مقطع چیست؟» و «دو دایره‌ی مماس» زیرِ «چند نقطه‌ی مشترک؟» جواب بود.
2. **چیزی را که سوال می‌پرسد نکشید.** اگر متن همه‌ی داده را داده (معادله، مختصات) و شکل نتیجه را هم نشان می‌دهد،
شکل کارِ دانش‌آموز را می‌کند. یا داده را فقط در شکل بگذارید (و متن آن را نگوید)، یا شکل را برنداشته سوال را بی‌شکل بگذارید.
مثال‌های بد: نقطه‌ای که «روی دایره است یا نه» و روی دایره کشیده شده؛ «لوزی با یک زاویه‌ی قائمه چه نام دارد» با مربع کشیده‌شده؛
خطی که «وضعیتش نسبت به دایره» پرسیده می‌شود و مماس کشیده شده؛ «نمودار x = ۱ چیست» با همان خط.
3. **مقدار را روی مماس، `drop` و ابزارهایی که خودشان عدد می‌نویسند نگذارید** (ابزارِ `tangent` «شیب = …» را چاپ می‌کند؛
مماس را با معادله‌ی خط بکشید).
4. هر سوالِ شکل‌دار را قبل از ادغام یک بار «به‌جای دانش‌آموز» بخوانید: اگر فقط با نگاه به شکل، بی‌هیچ فکر، جواب می‌شود،
دوباره بنویسید یا بی‌شکل بگذارید.
اصلاحِ سوالِ منتشرشده: `tools/figures/apply_fixups.py` (حذف و خالی‌کردنِ عنوان، هم روی seed هم با `--db` روی سرور،
پس از `pg_dump`)؛ seed خودش چیزی را بازنویسی نمی‌کند.

View File

@@ -0,0 +1,79 @@
"""
اصلاحِ سوال‌های شکل‌دارِ از قبل منتشرشده (seed هیچ‌وقت حذف یا بازنویسی نمی‌کند، پس اصلاح یک ابزارِ جدا می‌خواهد).
python tools/figures/apply_fixups.py fixups.json # فقط فایل‌های seed محلی
venv/bin/python tools/figures/apply_fixups.py --db fixups.json # دیتابیسِ همین ماشین (روی سرور، پس از pg_dump)
fixups.json: {"delete": [[path, prompt], ...], "blank_title": [[path, prompt], ...]}
delete سوال را کامل پاک می‌کند (پاسخ‌های ثبت‌شده‌اش هم می‌رود؛ فقط برای سوالِ تازه‌منتشرشده)
blank_title عنوانِ زیرِ شکل را خالی می‌کند (عنوان گاهی جوابِ سوال را لو می‌داد)
سوال با (path, prompt) شناخته می‌شود؛ هر کدام پیدا نشود فهرست می‌شود، بی‌صدا رد نمی‌شود.
"""
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, ROOT)
def key(path, prompt):
return (tuple(path), prompt.strip())
def apply_seed(fx):
p = os.path.join(ROOT, 'content', 'seed', 'questions.json')
data = json.load(open(p))
dele = {key(*x) for x in fx.get('delete', [])}
blank = {key(*x) for x in fx.get('blank_title', [])}
seen_d, seen_b, out = set(), set(), []
for q in data['questions']:
k = key(q['path'], q['prompt'])
if k in dele:
seen_d.add(k)
continue
if k in blank and q.get('figure'):
q['figure']['title'] = ''
seen_b.add(k)
out.append(q)
data['questions'] = out
json.dump(data, open(p, 'w'), ensure_ascii=False, indent=1)
return dele - seen_d, blank - seen_b
def apply_db(fx):
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django
django.setup()
from content.models import Question
lessons = {}
from content.models import Lesson
for l in Lesson.objects.select_related('node__unit__grade'):
lessons[(l.node.unit.grade.title, l.node.unit.title, l.node.title, l.title)] = l.pk
miss_d, miss_b = set(), set()
for path, prompt in fx.get('delete', []):
lid = lessons.get(tuple(path))
qs = Question.objects.filter(lesson_id=lid, prompt=prompt.strip()) if lid else Question.objects.none()
if not qs.exists():
miss_d.add(key(path, prompt))
else:
qs.delete()
for path, prompt in fx.get('blank_title', []):
lid = lessons.get(tuple(path))
qs = list(Question.objects.filter(lesson_id=lid, prompt=prompt.strip())) if lid else []
if not qs:
miss_b.add(key(path, prompt))
for q in qs:
if q.figure:
q.figure = {**q.figure, 'title': ''}
q.save(update_fields=['figure'])
return miss_d, miss_b
if __name__ == '__main__':
args = [a for a in sys.argv[1:] if a != '--db']
fx = json.load(open(args[0]))
miss_d, miss_b = (apply_db if '--db' in sys.argv else apply_seed)(fx)
print(f'deleted/blanked ok; not found: delete={len(miss_d)} blank_title={len(miss_b)}')
for k in list(miss_d)[:5] + list(miss_b)[:5]:
print(' missing:', k[1][:60])