diagrams for every grade, sectioned prompt, repair of real model slips; fill lesson texts and thin islands

Diagrams: 14 kinds drawn by the app (plane with constructed points, graphs with
computed roots/extrema/tangents, number lines with intervals, solids, charts,
tables, Venn, mapping, trees, fractions, grids, groups, clock). The catalog is
split by topic and only the matching sections go next to a question (200-1073
tokens instead of 1854). parse() repairs slips seen on 96 real book problems:
Persian digits in JSON numbers, 2*pi, // comments, bare items, Venn with
Persian set names. Measured: 45/48 valid on the second round, 0/8 on plain
arithmetic; visual review of 83: 64 right, 3 weak, 16 wrong.

Content: 82 new questions so no island has fewer than four; fill_lesson_texts
writes Unit.blurb (58) and Lesson.example/steps (590) from verified book
problems, never the lesson's own quiz problem. seed_content only fills empty
fields, so admin edits survive a deploy. Tedi unlocks at 500 xp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 23:22:13 +03:30
parent 33d8f43d74
commit 0917ab487f
10 changed files with 7434 additions and 745 deletions

View File

@@ -388,19 +388,29 @@ preferred setup, because there is then no key to leak. **Never let the server ru
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
anyone who can read this repo can forge a token for any user.**
## وضعیت ۲۰۲۶-۰۹-۲۸ — شکل در دستیار، آزمونِ جزیره، همراهِ چهارم (هنوز دیپلوی نشده)
## وضعیت ۲۰۲۶-۰۹-۲۸ — شکل در دستیار، آزمونِ جزیره، همراهِ چهارم، محتوای کامل (هنوز دیپلوی نشده)
- **شکل در جوابِ دستیار:** مدل در انتهای جواب یک بلوکِ `<diagram>{JSON}</diagram>` می‌نویسد؛
`rag/services/diagram.py` آن را پیش از `mathtext` جدا و سخت‌گیرانه اعتبارسنجی می‌کند، روی
- ⛔ **اعتبارِ GapGPT تمام شد (همان روز، ~۱۷:۲۰) و دستیارِ سایتِ زنده ۵۰۳ می‌دهد.** کلیدِ
`.env`ِ لپ‌تاپ همان حسابِ سرور است؛ سنجش‌های شکل (~۲۵۰ فراخوانی، ≈۰٫۳ دلار) باقی‌مانده را
خورد. **هر سنجشِ انبوهِ محلی از اعتبارِ پروداکشن خرج می‌کند؛ پیش از آن موجودی را بسنجید.**
- **شکل در جوابِ دستیار:** مدل در انتهای جواب `<diagram>{JSON}</diagram>` می‌نویسد؛
`rag/services/diagram.py` آن را پیش از `mathtext` جدا، خطاهای قالبیِ شناخته‌شده را تعمیر
(رقمِ فارسی، `2*pi`، کامنت، ونِ با اسمِ فارسی) و سخت‌گیرانه اعتبارسنجی می‌کند؛ روی
`Message.diagram` (migration `ai_tutor.0006`) می‌نشیند و در `done`ِ SSE و تاریخچه می‌آید.
فرانت (`components/Diagram.tsx`) خودش SVG می‌کشد؛ HTML/SVGِ مدل هرگز وارد صفحه نمی‌شود.
پرامپت `tutor-v6-diagrams`. سنجش با gpt-4.1-mini: قاعده فقط در پرامپتِ سیستم ۳ از ۱۲
شکل می‌داد؛ با یادآوریِ کنارِ سوال (`hint_for`، بر اساسِ کلمه‌های شکل‌دار) ۱۲ از ۱۲، و ۰ از ۸
برای سوالِ بی‌شکل. فقط ~۶۰ فراخوانی روی ۸ سوال سنجیده شده.
- **فرانت آزمونِ جزیره را بازی می‌کند** (`/nodes/{id}/quiz`): هر درس فقط یک سوال دارد و
درس‌به‌درس یعنی ~۱۱ دور برای هر جزیره. خانه حالا خودِ مسیر است با دکمه‌ی «شروع».
- **همراهِ چهارم «تدی»** (شکلکِ `mobile-preview/mascot-pack`، شش حالت) با migration
`progress.0005` (slugِ `tedi`، ۶۰۰ امتیاز؛ اسم و امتیاز تصمیمِ محصولی).
فرانت (`src/diagram/`) خودش SVG می‌کشد و ریشه، رأس، مماس، پای عمود و تبدیل‌ها را حساب
می‌کند؛ HTML/SVGِ مدل هرگز وارد صفحه نمی‌شود. ۱۴ نوع (plane، numberline، fraction، grid،
groups، clock، bar/linechart/pie، table، venn، mapping، tree، solid)؛ گالریِ آزمایشی در
`?gallery` فقط در حالتِ توسعه. کاتالوگ بخش‌بخش است و کنارِ هر سوال فقط بخشِ مربوط می‌رود
(`hint_for`). پرامپت `tutor-v6-diagrams`. سنجش روی ۹۶ مسئله‌ی واقعیِ کتاب: ۴۵ از ۴۸ شکلِ
معتبر (دورِ ۲)، ۰ از ۸ برای سوالِ بی‌شکل؛ وارسیِ چشمیِ ۸۳ شکل: ۶۴ درست، ۳ ضعیف، ۱۶ غلط.
اصلاح‌های بعد از آن (خط از معادله، کسرِ مقایسه‌ای، محرکِ ساعت، …) روی مدل سنجیده نشده‌اند.
- **فرانت آزمونِ جزیره را بازی می‌کند** (`/nodes/{id}/quiz`); خانه خودِ مسیر است با «شروع».
- **تدی** (شکلکِ `mobile-preview/mascot-pack`، شش حالت) با `progress.0005`، **۵۰۰ امتیاز**
(همان فاصله‌ی ۲۰۰تاییِ دلتا تا پی؛ خواستِ صاحبِ پروژه).
- **محتوا:** ۸۲ سوالِ تازه، پس هیچ جزیره‌ای زیرِ ۴ سوال نیست (۲٬۵۴۵ سوال). `fill_lesson_texts`
(بی‌هزینه) `Unit.blurb` را برای ۵۸ فصل و مثال/قدم‌ها را برای ۵۹۰ درس از مسئله‌های تأییدشده‌ی
کتاب پر کرد، هرگز همان مسئله‌ی آزمونِ درس؛ `seed_content` این‌ها را فقط در خانه‌ی خالی
می‌نشاند، پس متنِ ادمین با دیپلوی بازنویسی نمی‌شود.
- محلی: `PUBLIC_BASE_URL` پیش‌فرضش دامنه‌ی اصلی است، پس «پرداخت» روی لپ‌تاپ به سرورِ واقعی
می‌رود و ۴۰۴ می‌گیرد؛ باگِ سرور نیست. گزارش: `~/Desktop/گزارشات Tadrisino/گزارش-۲۵/`.

View File

@@ -93,6 +93,8 @@ def parse(raw: bytes) -> list[dict]:
{
'title': _text(lesson.get('title'), 'title', f'{nwhere} › نوع {li + 1}'),
'intro_text': (lesson.get('introText') or lesson.get('intro_text') or ''),
'example': str(lesson.get('example') or ''),
'steps': [str(x) for x in (lesson.get('steps') or []) if str(x).strip()][:12],
}
for li, lesson in enumerate(lessons)
],
@@ -100,6 +102,7 @@ def parse(raw: bytes) -> list[dict]:
clean_units.append({
'title': _text(unit.get('title'), 'title', uwhere),
'blurb': str(unit.get('blurb') or '')[:120],
'order': int(unit.get('order') or ui + 1),
'nodes': clean_nodes,
})
@@ -136,6 +139,10 @@ def import_curriculum(grades: list[dict]) -> dict:
defaults={'order': unit_row['order']},
)
counts['units'] += 1 if created else 0
# متنِ ادمین برنده است: فقط خانه‌ی خالی از فایل پر می‌شود
if unit_row.get('blurb') and not unit.blurb:
unit.blurb = unit_row['blurb']
unit.save(update_fields=['blurb'])
for node_row in unit_row['nodes']:
node, created = Node.objects.get_or_create(
@@ -149,12 +156,24 @@ def import_curriculum(grades: list[dict]) -> dict:
node=node, title=lesson_row['title'],
defaults={'intro_text': lesson_row['intro_text']},
)
changed = []
if created:
counts['lessons'] += 1
elif lesson_row['intro_text'] and lesson.intro_text != lesson_row['intro_text']:
lesson.intro_text = lesson_row['intro_text']
lesson.save(update_fields=['intro_text'])
counts['updated'] += 1
changed.append('intro_text')
# مثال و قدم‌ها فقط وقتی از فایل می‌آیند که در دیتابیس خالی‌اند؛ آنچه
# کسی از پنلِ ادمین نوشته با دیپلوی بازنویسی نمی‌شود.
if lesson_row.get('example') and not lesson.example:
lesson.example = lesson_row['example']
changed.append('example')
if lesson_row.get('steps') and not lesson.steps:
lesson.steps = lesson_row['steps']
changed.append('steps')
if changed:
lesson.save(update_fields=changed)
if not created:
counts['updated'] += 1
return counts
@@ -171,12 +190,17 @@ def export_curriculum(grades_queryset) -> dict:
{
'title': unit.title,
'order': unit.order,
**({'blurb': unit.blurb} if unit.blurb else {}),
'nodes': [
{
'title': node.title,
'order': node.order,
'lessons': [
{'title': lesson.title, 'introText': lesson.intro_text}
{
'title': lesson.title, 'introText': lesson.intro_text,
**({'example': lesson.example} if lesson.example else {}),
**({'steps': lesson.steps} if lesson.steps else {}),
}
for lesson in node.lessons.all()
],
}

View File

@@ -0,0 +1,175 @@
"""
پر کردنِ متن‌های خالیِ درس و فصل از خودِ کتاب — بدونِ هیچ فراخوانیِ پولی.
python manage.py fill_lesson_texts # همه‌ی پایه‌ها
python manage.py fill_lesson_texts --dry-run # فقط گزارش
- `Unit.blurb`: یک جمله از عنوانِ جزیره‌های همان فصل («در این فصل: …»).
- `Lesson.example` و `Lesson.steps`: یک مسئله‌ی حل‌شده‌ی خودِ کتاب برای همان درس،
فقط از تکه‌هایی که راه‌حلشان بازبینی و تأیید شده (`solution_verdict='agrees'`)،
با جوابِ بازسنجی‌شده (`checked_answer`) در قدمِ آخر.
دو قاعده که عمداً اینجاست:
1. **فقط خانه‌ی خالی پر می‌شود.** متنی که کسی از پنلِ ادمین نوشته دست نمی‌خورد، و
اجرای دوباره چیزی را عوض نمی‌کند.
2. **مثال همان مسئله‌ی آزمونِ درس نیست.** سوالِ آزمون از اولین تکه‌ی تأییدشده‌ی درس
ساخته شده (generate_questions_from_chunks)؛ اینجا از دومی به بعد برداشته می‌شود و
تکه‌ای که جوابش با جوابِ درستِ سوالِ همان درس یکی است کنار می‌رود. بدونِ این،
صفحه‌ی درس جوابِ آزمون را پیشاپیش لو می‌داد. آزموده نشده: اینکه بازنویسیِ مدل در
ساختِ سوال جواب را عوض کرده باشد و این مقایسه آن را نبیند.
"""
import re
from django.core.management.base import BaseCommand
from django.db import transaction
from content.management.commands.generate_questions_from_chunks import Command as QuestionCommand
from content.models import Choice, Grade, Lesson, Unit
from rag.models import LessonChunk
from rag.services.config import build_collection_name
from rag.services.mathtext import to_plain
from rag.services.textbooks import TEXTBOOKS
BLURB_MAX = 120
STEP_MAX = 6
STEP_CHARS = 320
EXAMPLE_CHARS = 700
_DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
def _norm(text: str) -> str:
"""برای مقایسه‌ی جواب‌ها: رقمِ لاتین، بی‌فاصله، بی‌علامتِ اضافه."""
return re.sub(r'[\s٫.,،:]+', '', (text or '').translate(_DIGITS)).lower()
# کلمه‌های عنوانِ درس که چیزی درباره‌ی موضوع نمی‌گویند
_STOP = {'و', 'از', 'با', 'در', 'به', 'برای', 'یک', 'دو', 'های', 'ها', 'آن', 'این', 'حل', 'محاسبه', 'تعیین',
'پیدا', 'کردن', 'نوشتن', 'رسم', 'مسئله', 'مسائل', 'تمرین', 'مقدار', 'عدد', 'اعداد', 'شکل', 'کمک',
'داده', 'شده', 'تعداد', 'ممکن', 'شرایط', 'درست', 'نادرست', 'عبارت', 'عبارت‌های', 'استفاده', 'رابطه',
'بررسی', 'تشخیص', 'یافتن', 'نمایش', 'مقایسه', 'کاربرد', 'مفهوم', 'روش', 'انواع', 'ساده', 'طول'}
# مسئله‌ای که به شکل یا جدولی اشاره می‌کند که در متن نیست، به‌عنوانِ مثال به کار نمی‌آید
_FIGURE_REF = re.compile(r'شکل|روبه‌رو|روبرو|مقابل|صفحه(?:ٔ|ی)?\s*قبل|فعالیت|داده شده را|جدول زیر|نمودار(?: زیر|\s*ون|\s*توابع)|زیر را|توابع f و g|با توجه به نمودار')
def related(lesson_title: str, chunk) -> bool:
"""
آیا مسئله به همین درس مربوط است؟ جفت‌شدن از روی بازه‌ی صفحه گاهی مسئله‌ی نامربوطِ همان
صفحه‌ها را می‌داد (برای «نمودارِ قدرمطلق» مسئله‌ای درباره‌ی نمایشِ چندجمله‌ای). دستِ‌کم
یک کلمه‌ی معنادارِ عنوان باید در نوع یا متنِ مسئله باشد.
"""
words = [w for w in re.split(r'[\s،,()«»\-/]+', lesson_title) if len(w) >= 3 and w not in _STOP]
if not words:
return False
haystack = f'{chunk.problem_type} {chunk.problem}'
return sum(w in haystack for w in words) >= min(2, len(words))
def unit_blurb(unit: Unit) -> str:
titles = [n.title.strip() for n in unit.nodes.order_by('order') if n.title.strip()]
if not titles:
return ''
head = 'در این فصل: '
parts: list[str] = []
for t in titles:
candidate = '، '.join(parts + [t])
if len(head) + len(candidate) + 1 > BLURB_MAX:
break
parts.append(t)
if not parts:
return (head + titles[0])[:BLURB_MAX - 1] + '…'
text = '، '.join(parts[:-1]) + (f' و {parts[-1]}' if len(parts) > 1 else parts[0])
if len(parts) < len(titles):
text += ' و …'
return f'{head}{text}.'[:BLURB_MAX]
def split_steps(solution: str) -> list[str]:
"""راه‌حلِ کتاب → چند قدمِ کوتاه. اول با خط‌های جدا، بعد با جمله‌ها."""
plain = to_plain(solution or '').strip()
lines = [ln.strip(' -•\t') for ln in plain.splitlines() if ln.strip(' -•\t')]
if len(lines) <= 1:
lines = [s.strip() for s in re.split(r'(?<=[.؛!؟])\s+', plain) if s.strip()]
# شماره‌ی قدم را فرانت خودش می‌گذارد
lines = [re.sub(r'^(?:(?:گام|مرحله|قدم)\s*[0-9۰-۹]{1,2}\s*[::.\-)]?|[0-9۰-۹]{1,2}[).\-])\s*', '', ln) for ln in lines]
if len(lines) > STEP_MAX:
# قدم‌های اضافه در قدمِ آخر ادغام می‌شوند تا چیزی گم نشود
lines = lines[:STEP_MAX - 1] + [' '.join(lines[STEP_MAX - 1:])]
return [ln[:STEP_CHARS] for ln in lines if ln]
class Command(BaseCommand):
help = 'پر کردنِ Unit.blurb و Lesson.example/steps از کتاب (فقط خانه‌های خالی؛ بدونِ هزینه).'
def add_arguments(self, parser):
parser.add_argument('--dry-run', action='store_true', help='فقط گزارش بده؛ چیزی ننویس.')
def handle(self, *args, **opts):
dry = opts['dry_run']
counts = {'blurbs': 0, 'examples': 0, 'no_chunk': 0, 'kept': 0}
with transaction.atomic():
for unit in Unit.objects.prefetch_related('nodes'):
if unit.blurb.strip():
counts['kept'] += 1
continue
blurb = unit_blurb(unit)
if blurb:
unit.blurb = blurb
unit.save(update_fields=['blurb'])
counts['blurbs'] += 1
finder = QuestionCommand()
for book in TEXTBOOKS.values():
grade = Grade.objects.filter(title=f'پایه {book.grade}').first()
if grade is None:
continue
finder._book = book
collection = build_collection_name(book.subject, book.grade)
lessons = (Lesson.objects.filter(node__unit__grade=grade)
.select_related('node__unit').order_by('node__unit__order', 'node__order', 'id'))
for lesson in lessons:
if lesson.example.strip() or lesson.steps:
continue
chunk = self._example_chunk(finder, lesson, collection)
if chunk is None:
counts['no_chunk'] += 1
continue
steps = split_steps(chunk.solution)
answer = (chunk.checked_answer or '').strip()
if answer and not any(_norm(answer) in _norm(s) for s in steps[-2:]):
steps = (steps[:STEP_MAX - 1] if len(steps) >= STEP_MAX else steps) + [f'✅ جواب: {to_plain(answer)}']
lesson.example = to_plain(chunk.problem)[:EXAMPLE_CHARS]
lesson.steps = steps
lesson.save(update_fields=['example', 'steps'])
counts['examples'] += 1
if dry:
transaction.set_rollback(True)
prefix = 'اگر اجرا شود: ' if dry else ''
self.stdout.write(self.style.SUCCESS(
f'{prefix}{counts["blurbs"]} توضیحِ فصل و {counts["examples"]} مثالِ درس پر شد؛ '
f'{counts["no_chunk"]} درس مسئله‌ی تأییدشده‌ی دوم نداشت؛ '
f'{counts["kept"]} فصل از قبل متن داشت و دست نخورد.'
))
def _example_chunk(self, finder, lesson, collection):
"""دومین مسئله‌ی تأییدشده‌ی درس، که جوابش جوابِ سوالِ آزمونِ همین درس نباشد."""
by_title = list(
LessonChunk.objects.filter(collection=collection, problem_type=lesson.title,
solution_verdict=LessonChunk.VERDICT_AGREES)
.exclude(problem='').exclude(solution='').order_by('source_page', 'order')[:6]
)
candidates = by_title or finder._chunks_by_page(
lesson, collection, need=6, opts={'verified_only': True}, used=set(), skip=0)
quiz_answers = {
_norm(v) for v in Choice.objects.filter(question__lesson=lesson, is_correct=True).values_list('value', flat=True)
}
# اولی مالِ سوالِ آزمون است (همان ترتیبِ قطعیِ ساختِ سوال)
for chunk in candidates[1:]:
if not (chunk.solution or '').strip() or _FIGURE_REF.search(chunk.problem):
continue
if not by_title and not related(lesson.title, chunk):
continue
if _norm(chunk.checked_answer) and _norm(chunk.checked_answer) in quiz_answers:
continue
return chunk
return None

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
"""متن‌های درس و فصل: پر شدن از کتاب، و اینکه دیپلوی ویرایشِ ادمین را بازنویسی نکند."""
import json
from django.core.management import call_command
from django.test import TestCase
from content.curriculum_io import export_curriculum, import_curriculum, parse
from content.management.commands.fill_lesson_texts import related, split_steps, unit_blurb
from content.models import Choice, Grade, Lesson, Node, Question, Unit
from rag.models import LessonChunk
def _curriculum(blurb='', example='', steps=None):
return json.dumps({'grades': [{'title': 'پایه آزمون', 'units': [{
'title': 'فصل ۱', 'blurb': blurb, 'nodes': [{'title': 'جزیره', 'lessons': [
{'title': 'درس', 'introText': 'توضیح', 'example': example, 'steps': steps or []},
]}],
}]}]}).encode()
class SeedKeepsAdminEditsTests(TestCase):
def test_empty_fields_are_filled_from_the_file(self):
import_curriculum(parse(_curriculum()))
import_curriculum(parse(_curriculum('در این فصل: جزیره.', 'مثال', ['قدم ۱'])))
lesson = Lesson.objects.get(title='درس')
self.assertEqual((lesson.example, lesson.steps), ('مثال', ['قدم ۱']))
self.assertEqual(Unit.objects.get(title='فصل ۱').blurb, 'در این فصل: جزیره.')
def test_text_written_in_admin_survives_the_next_deploy(self):
import_curriculum(parse(_curriculum()))
Lesson.objects.filter(title='درس').update(example='نوشته‌ی ادمین', steps=['قدمِ ادمین'])
Unit.objects.filter(title='فصل ۱').update(blurb='توضیحِ ادمین')
import_curriculum(parse(_curriculum('از فایل', 'از فایل', ['از فایل'])))
lesson = Lesson.objects.get(title='درس')
self.assertEqual((lesson.example, lesson.steps), ('نوشته‌ی ادمین', ['قدمِ ادمین']))
self.assertEqual(Unit.objects.get(title='فصل ۱').blurb, 'توضیحِ ادمین')
def test_export_round_trips(self):
import_curriculum(parse(_curriculum('توضیح', 'مثال', ['الف', 'ب'])))
out = export_curriculum(Grade.objects.filter(title='پایه آزمون'))
unit = out['grades'][0]['units'][0]
self.assertEqual(unit['blurb'], 'توضیح')
self.assertEqual(unit['nodes'][0]['lessons'][0]['steps'], ['الف', 'ب'])
class HelpersTests(TestCase):
def test_blurb_lists_islands_and_stays_short(self):
grade = Grade.objects.create(title='پایه تست')
unit = Unit.objects.create(grade=grade, title='فصل', order=1)
for i, t in enumerate(['عددهای صحیح', 'عددهای گویا', 'جمع و تفریق']):
Node.objects.create(unit=unit, title=t, order=i)
self.assertEqual(unit_blurb(unit), 'در این فصل: عددهای صحیح، عددهای گویا و جمع و تفریق.')
for i in range(20):
Node.objects.create(unit=unit, title=f'جزیره‌ی بسیار طولانیِ شماره‌ی {i}', order=10 + i)
self.assertLessEqual(len(unit_blurb(unit)), 120)
def test_steps_drop_their_own_numbering(self):
self.assertEqual(split_steps('گام 1: ضرایب\nگام 2: توان‌ها\n3) نتیجه'), ['ضرایب', 'توان‌ها', 'نتیجه'])
def test_relevance_needs_two_real_words(self):
chunk = LessonChunk(problem_type='معادله‌ی خط', problem='خط y=2x+3 را رسم کن و شیب خط را بگو')
self.assertTrue(related('رسم خط با معادله و شیب', chunk))
self.assertFalse(related('محاسبه تعداد مثلث‌های ممکن با شرایط داده شده', chunk))
class FillCommandTests(TestCase):
"""مثالِ درس از دومین مسئله‌ی تأییدشده، هرگز همان مسئله‌ی آزمون."""
def setUp(self):
grade = Grade.objects.create(title='پایه هشتم', number=8)
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
node = Node.objects.create(unit=unit, title='جزیره', order=1)
self.lesson = Lesson.objects.create(node=node, title='جمع کسرها', intro_text='x')
q = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='۱/۲ + ۱/۴ = ?')
Choice.objects.create(question=q, value='3/4', is_correct=True)
common = {'collection': 'ریاضی هشتم', 'problem_type': 'جمع کسرها', 'solution_verdict': 'agrees',
'grade_title': 'هشتم', 'lesson_number': 1, 'chapter': 1, 'subject': 'ریاضی'}
LessonChunk.objects.create(order=1, problem='اولی: ۱/۲ + ۱/۴', solution='مخرج مشترک ۴\nجواب ۳/۴', checked_answer='3/4', **common)
LessonChunk.objects.create(order=2, problem='دومی با همان جواب', solution='...\n۳/۴', checked_answer='3 / 4', **common)
LessonChunk.objects.create(order=3, problem='سومی: ۱/۳ + ۱/۶', solution='مخرج مشترک ۶\n۲/۶ + ۱/۶ = ۳/۶', checked_answer='1/2', **common)
def test_example_skips_the_quiz_problem_and_its_answer(self):
call_command('fill_lesson_texts', verbosity=0)
self.lesson.refresh_from_db()
self.assertTrue(self.lesson.example.startswith('سومی'))
self.assertEqual(self.lesson.steps[-1], '✅ جواب: 1/2')
def test_second_run_changes_nothing(self):
call_command('fill_lesson_texts', verbosity=0)
Lesson.objects.filter(pk=self.lesson.pk).update(example='ادمین')
call_command('fill_lesson_texts', verbosity=0)
self.lesson.refresh_from_db()
self.assertEqual(self.lesson.example, 'ادمین')

View File

@@ -2,9 +2,9 @@
همراهِ چهارم: «تدی»، شکلکِ زردِ پیش‌نمایشِ موبایل (mobile-preview/mascot-pack) که
حالا در فرانت کنارِ سه‌یار است و همان شش حالتِ چهره را دارد.
نام و امتیازِ باز شدن تصمیمِ محصولی‌اند و از ادمین قابلِ تغییرند؛ ۶۰۰ یعنی بعد از
پی (۳۰۰)، حدودِ ۶۰ جوابِ درست. get_or_create است، پس ویرایشِ بعدیِ ادمین را
برنمی‌گرداند. فرانت فقط slugِ tedi را به این شکلک نگاشت می‌کند.
امتیازِ باز شدن ۵۰۰ است: همان فاصله‌ی ۲۰۰تاییِ دلتا (۱۰۰) تا پی (۳۰۰)، به خواستِ صاحبِ
پروژه (۲۰۲۶-۰۹-۲۸). نام و امتیاز از ادمین قابلِ تغییرند؛ get_or_create است، پس ویرایشِ
بعدیِ ادمین را برنمی‌گرداند. فرانت فقط slugِ tedi را به این شکلک نگاشت می‌کند.
"""
from django.db import migrations
@@ -12,7 +12,7 @@ from django.db import migrations
def create(apps, schema_editor):
apps.get_model('progress', 'Character').objects.get_or_create(
slug='tedi',
defaults={'name': 'تدی', 'icon': 'tedi', 'unlock_xp': 600, 'order': 4},
defaults={'name': 'تدی', 'icon': 'tedi', 'unlock_xp': 500, 'order': 4},
)

View File

@@ -265,4 +265,4 @@ class FourCompanionsCatalogTests(TestCase):
Character.objects.filter(slug__in=['sigma', 'delta', 'pi', 'tedi'])
.order_by('order').values_list('slug', 'unlock_xp')
)
self.assertEqual(rows, [('sigma', 0), ('delta', 100), ('pi', 300), ('tedi', 600)])
self.assertEqual(rows, [('sigma', 0), ('delta', 100), ('pi', 300), ('tedi', 500)])

View File

@@ -1,20 +1,25 @@
"""
شکلِ پاسخِ دستیار: مدل یک توصیفِ JSONِ کوتاه در <diagram>…</diagram> می‌نویسد و
اپ خودش آن را به SVG تبدیل می‌کند.
اپ خودش آن را به SVG تبدیل می‌کند (MathApp_frontend، src/diagram).
چرا مدل خودش SVG/HTML نمی‌نویسد (سنجیده ۲۰۲۶-۰۹-۲۸ روی gpt-4.1-mini):
- **امنیت:** HTMLِ مدل (که متنِ کتاب و سوالِ کاربر را هم خوانده) اگر مستقیم در
صفحه بنشیند، راهِ تزریقِ اسکریپت است. JSONِ اعتبارسنجی‌شده فقط داده است.
- **درستیِ هندسه:** مدل در حسابِ مختصات (cos 30° و …) خطا می‌کند؛ اینجا فقط
می‌گوید «نقطه روی دایره با زاویه‌ی ۳۰°» و فرانت مختصات را حساب می‌کند.
- **هزینه و سرعت:** یک JSONِ ۱۰۰–۲۰۰ توکنی به‌جای SVGِ چندصدتوکنی.
می‌گوید «نقطه روی دایره با زاویه‌ی ۳۰°»، «وسطِ AB»، «ریشه‌های تابع» و فرانت
همه را دقیق حساب می‌کند.
- **هزینه و سرعت:** یک JSONِ ۱۰۰–۳۰۰ توکنی به‌جای SVGِ چندصدتوکنی.
- Structured Outputs (json_schema) را این مدل دارد ولی کلِ پاسخ را JSON
می‌کند و استریمِ متن را خراب می‌کند؛ برای همین فقط بلوکِ آخر JSON است.
این ماژول سه کار می‌کند: قاعده‌ی پرامپت (`DIAGRAM_RULE`)، جدا کردنِ بلوک از متن
(قبل از `mathtext`، که JSON را خراب می‌کرد)، و اعتبارسنجیِ سخت‌گیر. شکلِ
نامعتبر بی‌صدا دور ریخته می‌شود؛ متنِ پاسخ هیچ‌وقت به‌خاطرِ شکل از دست نمی‌رود.
آزموده نشده: رفتارِ مدل‌های دیگر (فقط gpt-4.1-mini سنجیده شد، ۱۸ سوال).
دو تکه‌ی پرامپت: `DIAGRAM_RULE` کوتاه است و همیشه در پرامپتِ سیستم می‌آید؛
`DIAGRAM_SPEC` (کاتالوگِ کاملِ نوع‌ها) فقط کنارِ سوالی می‌آید که کلمه‌ی شکل‌دار دارد
(`hint_for`)، تا سوالِ حسابیِ ساده هزینه‌ی توکنِ اضافه نداشته باشد.
این ماژول قاعده‌ها را می‌دهد، بلوک را از متن جدا می‌کند (قبل از `mathtext`، که JSON را
خراب می‌کرد) و سخت‌گیرانه اعتبارسنجی می‌کند: فقط کلید و نوع و رنگِ شناخته‌شده، عددِ
متناهی و محدود، متنِ کوتاه. تکه‌ی نامعتبر بی‌صدا حذف می‌شود؛ متنِ پاسخ هیچ‌وقت
به‌خاطرِ شکل از دست نمی‌رود. آزموده نشده: رفتارِ مدل‌های دیگر جز gpt-4.1-mini.
"""
import json
import logging
@@ -28,65 +33,142 @@ _BLOCK = re.compile(r'<diagram>(.*?)(?:</diagram>|$)', re.S)
DIAGRAM_RULE = (
'شکل (اپ این را به تصویر تبدیل می‌کند):\n'
'- وقتی سوال درباره‌ی یکی از این‌هاست، در پایانِ پاسخ دقیقاً یک بلوکِ '
'<diagram>…</diagram> بگذار: شکلِ هندسی (مثلث، چندضلعی، دایره، زاویه، خطِ موازی)، '
'دایره‌ی مثلثاتی، حرکت یا مقایسه روی محورِ اعداد، نقطه یا نمودار در صفحه‌ی مختصات، '
'کسر یا درصد به‌صورتِ تکه‌های یک شکل. هر سوالی که کلمه‌ی مثلث، دایره، زاویه، محور، '
'نمودار، کسر یا تکه دارد، شکل می‌خواهد. فقط برای حسابِ عددی یا ساده‌کردنِ عبارتِ '
'جبری که شکلی ندارد، شکل نگذار.\n'
'- داخلِ بلوک فقط یک JSON است، نه متنِ دیگر، نه SVG، نه HTML. مختصات را خودت حساب '
'نکن؛ نقطه‌ی روی دایره را با polar بده ([شعاع، زاویه به درجه]) تا اپ دقیق رسمش کند.\n'
'- در متنِ پاسخ به شکل اشاره کن («در شکلِ پایین…») و برچسب‌های شکل همان نام‌های متن باشند.\n'
'- سه نوع شکل:\n'
' ۱) صفحه‌ی مختصات یا هندسه:\n'
' {"kind":"plane","title":"…","axes":true,"points":{"O":[0,0],"A":[1,0],"P":{"polar":[1,30]}},'
'"items":[{"type":"circle","center":"O","r":1},{"type":"segment","from":"O","to":"P"},'
'{"type":"angle","vertex":"O","from":"A","to":"P","label":"30°"},'
'{"type":"rightAngle","vertex":"H","from":"A","to":"B"},{"type":"polygon","points":["A","B","C"]},'
'{"type":"line","through":["A","B"]},{"type":"arrow","from":"A","to":"B"},'
'{"type":"point","point":"V","label":"رأس (1, −1)"},'
'{"type":"function","expr":"x^2-2*x","from":-1,"to":3,"label":"y = x² − 2x"},'
'{"type":"text","at":[0.5,1.2],"text":"…"}]}\n'
' نقطه‌ها با نامشان برچسب می‌خورند (برچسبِ دیگر با item نوعِ point)؛ نقطه‌ی کمکی که '
'برچسب نمی‌خواهد با نامِ شروع‌شده با «_» بیاید. ضلع یا اندازه را با label روی segment '
'بنویس: {"type":"segment","from":"A","to":"B","label":"۳"}. در function فقط x، عدد، '
'+ − * / ^ ، پرانتز و sin cos tan sqrt abs log بنویس. "axes": false برای شکلِ هندسیِ بی‌محور '
'(مثلث، چندضلعی)؛ true فقط برای صفحه‌ی مختصات و نمودار. line خطِ بی‌پایان است؛ ضلع، قطر و شعاع '
'همیشه segment‌اند.\n'
' ۲) محورِ اعداد: {"kind":"numberline","title":"…","min":-5,"max":5,'
'"points":[{"at":2,"label":"شروع"},{"at":-1,"label":"پایان"}],'
'"jumps":[{"from":2,"to":-1,"label":"۳ واحد به چپ"}]}\n'
' ۳) کسر: {"kind":"fraction","title":"…","shape":"circle","parts":4,"shaded":3}\n'
'- مثال: «وترِ مثلثِ قائم‌الزاویه با ضلع‌های ۳ و ۴» → polygon با سه رأس، rightAngle روی رأسِ قائمه '
'و label روی هر ضلع (وتر با «?» یا جوابش).\n'
'- title کوتاه و فارسی. حداکثر ۱۲ نقطه و ۱۲ item.'
'- اگر سوال شکل دارد (هندسه، حجم، محورِ اعداد، نمودارِ تابع، نمودارِ آماری، کسر، '
'مجموعه، احتمال، ساعت)، در پایانِ پاسخ دقیقاً یک بلوکِ <diagram>…</diagram> بگذار که '
'داخلش فقط یک JSON است، نه SVG و نه HTML. قالبِ دقیقش کنارِ همان سوال می‌آید.\n'
'- برای حسابِ عددی یا ساده‌کردنِ عبارتِ جبری که شکلی ندارد، شکل نگذار.'
)
# کلمه‌هایی که یعنی «این سوال شکل دارد». سنجشِ ۲۰۲۶-۰۹-۲۸: قاعده‌ی تهِ پرامپتِ
# سیستم به‌تنهایی برای مثلث ۱ از ۴ و پنج‌ضلعی ۰ از ۴ شکل می‌گذاشت؛ همان درسِ
# «/» — قاعده کنارِ خودِ سوال اثر دارد، نه تهِ پرامپت.
_SHAPE_WORDS = re.compile(
r'مثلث|دایره|زاویه|ضلع|چندضلعی|مربع|مستطیل|لوزی|ذوزنقه|متوازی|شعاع|قطر|وتر|محیط|مساحت|'
r'محور|نمودار|مختصات|سهمی|خط\s*راست|شیب|نقطه|کسر|تکه|پیتزا|کیک|درصد|سینوس|کسینوس|تانژانت|'
r'sin|cos|tan|°|درجه'
# کاتالوگِ نوع‌ها، بخش‌بخش. کنارِ هر سوال فقط بخش‌هایی می‌رود که کلمه‌هایش در سوال
# هست: کاتالوگِ کامل ۱٬۸۵۴ توکن بود (سنجیده با خودِ API، ۲۰۲۶-۰۹-۲۸) و هزینه‌ی هر
# سوالِ شکل‌دار را تقریباً دو برابر می‌کرد.
_HEAD = (
'قالبِ بلوکِ <diagram>: فقط یک JSON، کلیدها انگلیسی، برچسب‌ها فارسی یا ریاضی. هر شکل "kind" و '
'"title"ِ کوتاهِ فارسی دارد. مختصاتی را که قابلِ حساب است خودت حساب نکن؛ بگو «چه»، اپ «کجا» را '
'دقیق حساب می‌کند. رنگ فقط: blue red green orange purple teal pink gray black yellow. '
'در متنِ پاسخ به شکل اشاره کن («در شکلِ پایین…») با همان نام‌ها. یک شکلِ روشن بهتر از شکلِ شلوغ.'
)
_PLANE = (
'plane (هندسه و مختصات): {"kind":"plane","title":"…","axes":false,"grid":false,'
'"points":{"A":[0,0],"B":[4,0],"P":{"polar":[1,30]},"Q":{"polar":[2,60],"from":"A"},"M":{"mid":["A","B"]},'
'"H":{"foot":["C","A","B"]},"I":{"intersect":[["A","B"],["C","D"]]},"T":{"along":["A","B",0.25]}},"items":[…]}\n'
'نقطه: polar=[شعاع، درجه]؛ mid وسط؛ foot پای عمود از اولی بر خطِ دوتای بعد؛ intersect برخوردِ دو خط؛ along '
'نقطه‌ی روی AB به نسبتِ t. نامِ شروع‌شده با «_» برچسب نمی‌گیرد. axes=false برای مثلث و چندضلعی؛ grid=true برای '
'کاغذِ شطرنجی (دوران، بردار).\n'
'items (همه color، dashed و label اختیاری دارند): {"type":"point","point":"A"} · '
'{"type":"segment","from":"A","to":"B","label":"۵","ticks":1} (ticks علامتِ ضلعِ مساوی ۱ تا ۳) · '
'{"type":"vector","from":"A","to":"B"} · {"type":"line","through":["A","B"]} (خطِ بی‌پایان؛ ضلع و قطر segment‌اند) · '
'{"type":"line","eq":[3,5,4]} یعنی خطِ 3x + 5y = 4 (ضریب‌ها را عیناً از صورتِ مسئله بردار؛ خودت شیب حساب نکن) · '
'{"type":"ray","from":"A","through":"B"} · {"type":"circle","center":"O","r":2} یا "through":"A" · '
'{"type":"arc","center":"O","r":2,"start":0,"end":60} · {"type":"sector",…} · '
'{"type":"angle","vertex":"B","from":"A","to":"C","label":"۴۰°","arcs":1} (arcs ۱ تا ۳ برای زاویه‌های مساوی؛ '
'زاویه‌ی خارجیِ رأسِ B: نقطه‌ی "E":{"along":["A","B",1.5]} روی امتدادِ AB، بعد angle از E تا C) · '
'{"type":"rightAngle","vertex":"H","from":"A","to":"B"} · {"type":"polygon","points":["A","B","C"]} · '
'{"type":"image","of":["A","B","C"],"transform":{"rotate":{"center":"C","deg":-90}}} (نامِ تصویرها A′؛ '
'rotate مثبت=پادساعتگرد و «در جهتِ عقربه‌ها» منفی؛ یا {"reflect":"x"|"y"|"y=x"|["A","B"]}، {"translate":[2,1]}، '
'{"scale":{"center":"O","k":2}}) · {"type":"text","at":[1,2],"text":"…"}'
)
_GRAPH = (
'نمودارِ تابع (در plane با "axes":true؛ "trig":true یعنی محورِ x با مضربِ π؛ "xRange"/"yRange" اختیاری): '
'{"type":"function","expr":"x^2-2*x-3","from":-2,"to":4,"label":"y = x² − 2x − 3",'
'"marks":["roots","vertex","axis","yIntercept"]} (marks: roots ریشه، vertex رأس، extrema ماکزیمم/مینیمم، axis '
'محورِ تقارن؛ اپ دقیق حساب می‌کند) · {"type":"piecewise","pieces":[{"expr":"x+1","from":-2,"to":1,'
'"closedFrom":true,"closedTo":false},…]} (closed یعنی نقطه‌ی توپُر؛ برای حد) · '
'{"type":"tangent","expr":"x^2","at":1} · {"type":"area","expr":"sqrt(x)","from":0,"to":4} (سایه‌ی زیرِ نمودار؛ '
'expr2 برای بینِ دو نمودار) · {"type":"region","expr":"2*x-1","side":"above","strict":true} (نامعادله) · '
'{"type":"vline","x":2} و {"type":"hline","y":1} (مجانب) · {"type":"point","point":[1,2],"open":true} · '
'نقطه‌ی روی نمودار: {"fx":["x^2",3]}. در expr فقط x، عدد، + - * / ^، پرانتز و sin cos tan sqrt abs log ln pi e؛ '
'log₂x را log(x)/log(2) بنویس.'
)
_NUMBERLINE = (
'numberline: {"kind":"numberline","title":"…","min":-5,"max":5,"step":1,'
'"points":[{"at":2,"label":"شروع","open":false}],"jumps":[{"from":2,"to":-1,"label":"۳ واحد به چپ"}],'
'"intervals":[{"from":-2,"to":3,"closedFrom":true,"closedTo":false,"label":"[-2, 3)"},{"from":1,"to":null}]} '
'(null یعنی بی‌نهایت؛ برای کسر روی محور "step":0.25 و "fractions":true؛ فاصله روی یک مسیر هم با همین)'
)
_PARTS = (
'fraction: {"kind":"fraction","title":"…","shape":"circle"|"rect","parts":4,"shaded":3,"wholes":0} '
'(wholes تعدادِ واحدِ کامل برای عددِ مخلوط: دو و سه‌چهارم = wholes 2، parts 4، shaded 3؛ مقایسه‌ی چند کسر: '
'"compare":[{"parts":5,"shaded":1,"label":"یک‌پنجم"},{"parts":10,"shaded":9}] که نوارهای هم‌اندازه می‌کشد) · '
'grid (درصد، شمردنِ خانه): {"kind":"grid","title":"…","rows":5,"cols":5,"shaded":5} · '
'groups (مدلِ ضرب و تقسیم): {"kind":"groups","title":"…","groups":3,"each":4,"item":"circle"|"square"|"star"}. '
'کسرِ جبری (با x یا a) و ساده کردنِ عبارت شکل ندارد.'
)
_CLOCK = 'clock: {"kind":"clock","title":"…","hour":3,"minute":25}'
_CHARTS = (
'bar | linechart | pie: {"kind":"bar","title":"…","labels":["سیب","پرتقال"],"values":[5,8],"unit":"نفر"} '
'(درصدِ دایره‌ای را اپ حساب می‌کند)'
)
_TABLE = (
'table (جدولِ مقدار یا تعیینِ علامت، چپ به راست): {"kind":"table","title":"…",'
'"headers":["x","−∞","−1","","2","+∞"],"rows":[["y","+","0","−","0","+"]]}'
)
_SETS = (
'venn: {"kind":"venn","title":"…","sets":["A","B"],"regions":{"A":["1","2"],"AB":["3"],"B":["5"],"out":["6"]},'
'"shade":["AB"]} (ناحیه با نامِ مجموعه‌هایی که در آن‌اند؛ «A» یعنی فقط A؛ تا سه مجموعه) · '
'mapping (نمودارِ پیکانی): {"kind":"mapping","title":"…","left":["1","2"],"right":["3","5"],"arrows":[[0,0],[1,1]],'
'"leftLabel":"دامنه","rightLabel":"برد"}'
)
_TREE = (
'tree (احتمال، شمارش): {"kind":"tree","title":"…","root":{"label":"شروع","children":[{"label":"رو","p":"1/2",'
'"children":[…]},…]}} (حداکثر سه سطح)'
)
_SOLID = (
'solid: {"kind":"solid","title":"…","shape":"cube"|"cuboid"|"prism"|"pyramid"|"cylinder"|"cone"|"sphere"|"hemisphere",'
'"dims":{"a":5,"b":3,"c":4,"n":6,"r":2,"h":5},"labels":{"a":"5 cm","h":"h","r":"r"}} (a پهنا، b عمق، c ارتفاعِ '
'مکعب‌مستطیل؛ n ضلع‌های قاعده‌ی منشور/هرم؛ dims نسبتِ واقعی. منشورِ مستطیلی و جعبه = cuboid؛ prism فقط برای قاعده‌ی '
'مثلث، پنج‌ضلعی و شش‌ضلعی)'
)
_W = lambda words: re.compile('|'.join(words)) # noqa: E731
_GEOMETRY_WORDS = (r'مثلث', r'دایره(?!‌ای)', r'زاویه', r'ضلع', r'چندضلعی', r'مربع', r'مستطیل', r'لوزی', r'ذوزنقه',
r'متوازی', r'شعاع', r'قطر', r'وتر', r'محیط', r'مساحت', r'کمان', r'محاطی', r'مماس', r'موازی',
r'عمود', r'نیمساز', r'بردار', r'دوران', r'انتقال', r'تقارن', r'قرینه', r'بازتاب', r'مختصات',
r'نقطه', r'سینوس', r'کسینوس', r'تانژانت', r'مثلثاتی', r'sin', r'cos', r'tan', r'°', r'درجه')
_GRAPH_WORDS = (r'نمودار(?![\u0650\u200c]?\s*(?:ستونی|دایره‌ای|میله‌ای|درختی|پیکانی|ون|خط\s*شکسته))', r'تابع', r'سهمی', r'خط\s*راست',
r'شیب', r'مجانب', r'مشتق', r'اکسترمم', r'ماکزیمم', r'مینیمم', r'صعودی', r'نزولی', r'پیوسته',
r'حد\b', r'\blim', r'نامعادله', r'مماس', r'log', r'f\(x\)', r'y\s*=')
SECTIONS = [
(_W(_GEOMETRY_WORDS + _GRAPH_WORDS), _PLANE),
(_W(_GRAPH_WORDS), _GRAPH),
(_W((r'محور', r'بازه', r'نامعادله', r'واحد به')), _NUMBERLINE),
(_W((r'کسر', r'تکه', r'پیتزا', r'کیک', r'درصد', r'خانه', r'گروه', r'نصف', r'چهارم')), _PARTS),
# «۶ ساعت مطالعه» مدت است، نه ساعتِ دیواری؛ فقط عقربه یا «ساعت … و … دقیقه»
(_W((r'عقربه', r'ساعت\s*[0-9۰-۹]+\s*(?::|و)\s*[0-9۰-۹]+', r'ساعت\s*چند')), _CLOCK),
(_W((r'ستونی', r'میله‌ای', r'دایره‌ای', r'خط\s*شکسته', r'میانگین', r'آمار', r'فراوانی')), _CHARTS),
(_W((r'جدول', r'تعیین\s*علامت')), _TABLE),
(_W((r'مجموعه', r'\bون\b', r'اشتراک', r'اجتماع', r'پیکانی', r'دامنه', r'برد\b', r'زوج\s*مرتب')), _SETS),
(_W((r'احتمال', r'سکه', r'تاس', r'درختی')), _TREE),
(_W((r'مکعب', r'منشور', r'هرم', r'استوانه', r'مخروط', r'کره', r'حجم', r'نیم‌کره')), _SOLID),
]
DIAGRAM_SPEC = '\n'.join([_HEAD] + [text for _, text in SECTIONS])
DIAGRAM_HINT = (
'این سوال شکل دارد: در پایانِ پاسخ بلوکِ <diagram>…</diagram> را طبقِ قاعده‌ی «شکل» بگذار.'
'این سوال شکل دارد: در پایانِ پاسخ بلوکِ <diagram>…</diagram> را طبقِ این قالب بگذار.'
)
def hint_for(question: str) -> str:
"""یادآوریِ کنارِ سوال، فقط برای سوال‌هایی که کلمه‌ی شکل‌دار دارند."""
return DIAGRAM_HINT if _SHAPE_WORDS.search(question or '') else ''
"""یادآوری و فقط آن بخش‌هایی از کاتالوگ که به همین سوال می‌خورند؛ سوالِ بی‌شکل → ''."""
text = question or ''
parts = [section for pattern, section in SECTIONS if pattern.search(text)]
if not parts:
return ''
return '\n'.join([DIAGRAM_HINT, _HEAD, *parts])
MAX_POINTS = 16
MAX_ITEMS = 16
MAX_LABEL = 40
MAX_COORD = 1000
_EXPR_OK = re.compile(r'^[0-9x+\-*/^().,\s]*(?:(?:sin|cos|tan|sqrt|abs|log|ln|pi)[0-9x+\-*/^().,\s]*)*$')
_NAME_OK = re.compile(r'^[\w′\']{1,6}$')
COLORS = {'blue', 'red', 'green', 'orange', 'purple', 'teal', 'pink', 'gray', 'black', 'yellow'}
_FUNCS = r'(?:sin|cos|tan|sqrt|abs|log|ln|pi|e)'
_EXPR_OK = re.compile(rf'^(?:[0-9x+\-*/^().,\s]|{_FUNCS})*$')
_NAME_OK = re.compile(r"^[\w′']{1,6}$")
_MARKS = {'roots', 'vertex', 'extrema', 'yIntercept', 'axis'}
_SOLIDS = {'cube', 'cuboid', 'prism', 'pyramid', 'cylinder', 'cone', 'sphere', 'hemisphere'}
Invalid = (ValueError, KeyError, TypeError, IndexError)
def _num(value, lo=-MAX_COORD, hi=MAX_COORD):
@@ -94,17 +176,26 @@ def _num(value, lo=-MAX_COORD, hi=MAX_COORD):
raise ValueError('not a number')
if not math.isfinite(value) or not lo <= value <= hi:
raise ValueError('out of range')
return round(float(value), 4)
return round(float(value), 6)
def _label(value) -> str:
def _int(value, lo, hi):
return int(round(_num(value, lo, hi)))
def _clamp(value, lo, hi):
"""شمارِ علامت‌ها و خانه‌ها: بیش از حد محدود می‌شود، نه اینکه کلِ تکه حذف شود."""
return int(min(hi, max(lo, round(_num(value)))))
def _label(value, limit=MAX_LABEL) -> str:
if value is None:
return ''
if isinstance(value, (int, float)) and not isinstance(value, bool):
value = str(value)
if not isinstance(value, str):
raise ValueError('label')
return value.strip()[:MAX_LABEL]
return value.strip()[:limit]
def _xy(value):
@@ -113,18 +204,6 @@ def _xy(value):
return [_num(value[0]), _num(value[1])]
def _point_def(value):
if isinstance(value, dict) and 'polar' in value:
polar = value['polar']
if not (isinstance(polar, (list, tuple)) and len(polar) == 2):
raise ValueError('polar')
out = {'polar': [_num(polar[0], 0, MAX_COORD), _num(polar[1], -3600, 3600)]}
if 'from' in value:
out['from'] = _ref(value['from'])
return out
return _xy(value)
def _ref(value):
"""نامِ نقطه یا مختصاتِ مستقیم."""
if isinstance(value, str):
@@ -134,40 +213,312 @@ def _ref(value):
return _xy(value)
def _expr(value) -> str:
if not isinstance(value, str) or not value.strip() or len(value) > 80:
raise ValueError('expr')
compact = value.replace(' ', '')
if not _EXPR_OK.match(compact):
raise ValueError('expr chars')
return value.strip()
def _pair(value, conv):
if not (isinstance(value, (list, tuple)) and len(value) == 2):
raise ValueError('pair')
return [conv(value[0]), conv(value[1])]
def _point_def(value):
if isinstance(value, dict):
if 'polar' in value:
polar = value['polar']
out = {'polar': [_num(polar[0], 0, MAX_COORD), _num(polar[1], -3600, 3600)]}
if value.get('from') is not None:
out['from'] = _ref(value['from'])
return out
if 'mid' in value:
return {'mid': _pair(value['mid'], _ref)}
if 'foot' in value:
f = value['foot']
if not (isinstance(f, (list, tuple)) and len(f) == 3):
raise ValueError('foot')
return {'foot': [_ref(f[0]), _ref(f[1]), _ref(f[2])]}
if 'intersect' in value:
return {'intersect': _pair(value['intersect'], lambda line: _pair(line, _ref))}
if 'along' in value:
a = value['along']
if not (isinstance(a, (list, tuple)) and len(a) == 3):
raise ValueError('along')
return {'along': [_ref(a[0]), _ref(a[1]), _num(a[2], -10, 10)]}
if 'fx' in value:
e, x = value['fx']
return {'fx': [_expr(e), _num(x)]}
raise ValueError('point def')
return _xy(value)
def _style(item: dict, out: dict) -> dict:
color = item.get('color')
if color in COLORS:
out['color'] = color
if isinstance(item.get('dashed'), bool):
out['dashed'] = item['dashed']
if item.get('label') is not None:
label = _label(item['label'])
if label:
out['label'] = label
return out
def _transform(value) -> dict:
if not isinstance(value, dict):
raise ValueError('transform')
if 'rotate' in value:
r = value['rotate']
return {'rotate': {'center': _ref(r['center']), 'deg': _num(r['deg'], -3600, 3600)}}
if 'reflect' in value:
r = value['reflect']
return {'reflect': r if r in ('x', 'y', 'y=x') else _pair(r, _ref)}
if 'translate' in value:
return {'translate': _xy(value['translate'])}
if 'scale' in value:
sc = value['scale']
return {'scale': {'center': _ref(sc['center']), 'k': _num(sc['k'], -20, 20)}}
raise ValueError('transform kind')
def _range(item, lo_key='from', hi_key='to', lo=-5.0, hi=5.0):
a, b = _num(item.get(lo_key, lo)), _num(item.get(hi_key, hi))
if a >= b:
raise ValueError('range')
return a, b
def _plane_item(item: dict) -> dict:
kind = item.get('type')
out = {'type': kind}
if kind == 'circle':
out.update(center=_ref(item['center']), r=_num(item['r'], 0, MAX_COORD))
elif kind in ('segment', 'arrow'):
out.update(**{'from': _ref(item['from'])}, to=_ref(item['to']))
out: dict = {'type': kind}
if kind == 'point':
out['point'] = _ref(item['point'])
if isinstance(item.get('open'), bool):
out['open'] = item['open']
elif kind in ('segment', 'arrow', 'vector'):
out.update({'from': _ref(item['from']), 'to': _ref(item['to'])})
if kind == 'segment' and item.get('ticks') is not None:
out['ticks'] = _clamp(item['ticks'], 1, 3)
elif kind == 'line':
a, b = item['through']
out['through'] = [_ref(a), _ref(b)]
if item.get('eq') is not None:
eq = item['eq']
if not (isinstance(eq, (list, tuple)) and len(eq) == 3):
raise ValueError('eq')
a, b, c = (_num(v) for v in eq)
if a == 0 and b == 0:
raise ValueError('degenerate line')
out['eq'] = [a, b, c]
else:
out['through'] = _pair(item['through'], _ref)
elif kind == 'ray':
out.update({'from': _ref(item['from']), 'through': _ref(item['through'])})
elif kind == 'vline':
out['x'] = _num(item['x'])
elif kind == 'hline':
out['y'] = _num(item['y'])
elif kind == 'circle':
out['center'] = _ref(item['center'])
if item.get('through') is not None:
out['through'] = _ref(item['through'])
else:
out['r'] = _num(item['r'], 0, MAX_COORD)
if out['r'] <= 0:
raise ValueError('radius')
if isinstance(item.get('fill'), bool):
out['fill'] = item['fill']
elif kind in ('arc', 'sector'):
out.update(center=_ref(item['center']), r=_num(item['r'], 0, MAX_COORD),
start=_num(item.get('start', 0), -3600, 3600), end=_num(item.get('end', 90), -3600, 3600))
elif kind in ('angle', 'rightAngle'):
out.update(vertex=_ref(item['vertex']), **{'from': _ref(item['from'])}, to=_ref(item['to']))
out.update({'vertex': _ref(item['vertex']), 'from': _ref(item['from']), 'to': _ref(item['to'])})
if kind == 'angle' and item.get('arcs') is not None:
out['arcs'] = _clamp(item['arcs'], 1, 3)
elif kind == 'polygon':
points = item['points']
if not isinstance(points, list) or not 3 <= len(points) <= 12:
raise ValueError('polygon')
out['points'] = [_ref(p) for p in points]
elif kind == 'point':
out['point'] = _ref(item['point'])
if isinstance(item.get('fill'), bool):
out['fill'] = item['fill']
elif kind == 'function':
expr = item['expr']
if not isinstance(expr, str) or len(expr) > 80 or not _EXPR_OK.match(expr.replace(' ', '')):
raise ValueError('expr')
out.update(expr=expr, **{'from': _num(item.get('from', -5))}, to=_num(item.get('to', 5)))
if out['from'] >= out['to']:
raise ValueError('function range')
out['expr'] = _expr(item['expr'])
out['from'], out['to'] = _range(item)
marks = [m for m in (item.get('marks') or []) if m in _MARKS]
if marks:
out['marks'] = list(dict.fromkeys(marks))
elif kind == 'piecewise':
pieces = []
for piece in (item.get('pieces') or [])[:6]:
try:
a, b = _range(piece)
clean = {'expr': _expr(piece['expr']), 'from': a, 'to': b}
for key in ('closedFrom', 'closedTo'):
if isinstance(piece.get(key), bool):
clean[key] = piece[key]
pieces.append(clean)
except Invalid:
continue
if not pieces:
raise ValueError('pieces')
out['pieces'] = pieces
elif kind == 'tangent':
out.update(expr=_expr(item['expr']), at=_num(item['at']))
elif kind == 'area':
out['expr'] = _expr(item['expr'])
out['from'], out['to'] = _range(item)
if item.get('expr2') is not None:
out['expr2'] = _expr(item['expr2'])
elif kind == 'region':
out['expr'] = _expr(item['expr'])
out['side'] = item['side'] if item.get('side') in ('above', 'below') else 'above'
if item.get('from') is not None and item.get('to') is not None:
out['from'], out['to'] = _range(item)
if isinstance(item.get('strict'), bool):
out['strict'] = item['strict']
elif kind == 'image':
of = item['of']
if not isinstance(of, list) or not 1 <= len(of) <= 12:
raise ValueError('image of')
out['of'] = [_ref(p) for p in of]
out['transform'] = _transform(item['transform'])
names = item.get('names')
if isinstance(names, list):
out['names'] = [_label(n, 6) for n in names[:12]]
elif kind == 'text':
out.update(at=_xy(item['at']), text=_label(item.get('text')))
if not out['text']:
raise ValueError('empty text')
if item.get('color') in COLORS:
out['color'] = item['color']
return out
else:
raise ValueError(f'unknown item {kind!r}')
if kind != 'text' and item.get('label') is not None:
out['label'] = _label(item['label'])
return _style(item, out)
def _plane(spec: dict, title: str) -> dict | None:
raw_points = spec.get('points') or {}
if not isinstance(raw_points, dict):
return None
points = {}
for name, value in list(raw_points.items())[:MAX_POINTS]:
if isinstance(name, str) and _NAME_OK.match(name):
try:
points[name] = _point_def(value)
except Invalid:
continue
items = []
raw_items = spec.get('items') or []
if not isinstance(raw_items, list):
return None
for item in raw_items[:MAX_ITEMS]:
if isinstance(item, dict):
try:
items.append(_plane_item(item))
except Invalid:
continue
if not items and not points:
return None
# بی‌محور مگر مدل صریحاً بخواهد یا نمودارِ تابع باشد: مثلثی که محور
# گرفته بود عددهای محور را روی برچسبِ ضلع‌ها می‌انداخت (۲۰۲۶-۰۹-۲۸).
graphs = {'function', 'piecewise', 'tangent', 'area', 'region', 'vline', 'hline'}
axes = spec.get('axes')
if not isinstance(axes, bool):
axes = any(i['type'] in graphs for i in items)
out = {'kind': 'plane', 'title': title, 'axes': axes, 'points': points, 'items': items}
for key in ('grid', 'trig'):
if isinstance(spec.get(key), bool):
out[key] = spec[key]
for key in ('xRange', 'yRange'):
try:
a, b = _pair(spec[key], _num)
if a < b:
out[key] = [a, b]
except Invalid:
pass
return out
def _numberline(spec: dict, title: str) -> dict | None:
lo, hi = _num(spec.get('min', -5)), _num(spec.get('max', 5))
if not lo < hi or hi - lo > 10000:
return None
out = {'kind': 'numberline', 'title': title, 'min': lo, 'max': hi, 'points': [], 'jumps': []}
if spec.get('step') is not None:
try:
step = _num(spec['step'], 0.001, 100)
if (hi - lo) / step <= 40:
out['step'] = step
except Invalid:
pass
if isinstance(spec.get('fractions'), bool):
out['fractions'] = spec['fractions']
for p in (spec.get('points') or [])[:MAX_POINTS]:
try:
clean = {'at': _num(p['at']), 'label': _label(p.get('label'))}
if isinstance(p.get('open'), bool):
clean['open'] = p['open']
if p.get('color') in COLORS:
clean['color'] = p['color']
out['points'].append(clean)
except Invalid:
continue
for j in (spec.get('jumps') or [])[:MAX_ITEMS]:
try:
out['jumps'].append({'from': _num(j['from']), 'to': _num(j['to']), 'label': _label(j.get('label'))})
except Invalid:
continue
intervals = []
for iv in (spec.get('intervals') or [])[:4]:
try:
a = None if iv.get('from') is None else _num(iv['from'])
b = None if iv.get('to') is None else _num(iv['to'])
if a is not None and b is not None and a >= b:
continue
clean = {'from': a, 'to': b}
for key in ('closedFrom', 'closedTo'):
if isinstance(iv.get(key), bool):
clean[key] = iv[key]
if iv.get('color') in COLORS:
clean['color'] = iv['color']
if iv.get('label'):
clean['label'] = _label(iv['label'])
intervals.append(clean)
except Invalid:
continue
if intervals:
out['intervals'] = intervals
return out
def _strings(values, limit, size=MAX_LABEL) -> list[str]:
if not isinstance(values, list):
raise ValueError('list')
return [_label(v, size) for v in values[:limit]]
def _tree(node, depth=0) -> dict:
if not isinstance(node, dict):
raise ValueError('tree node')
out = {'label': _label(node.get('label'), 12)}
if node.get('p') is not None:
out['p'] = _label(node['p'], 10)
children = node.get('children') or []
if depth < 3 and isinstance(children, list) and children:
kids = []
for child in children[:6]:
try:
kids.append(_tree(child, depth + 1))
except Invalid:
continue
if kids:
out['children'] = kids
return out
@@ -185,57 +536,128 @@ def validate(spec) -> dict | None:
title = ''
try:
if kind == 'plane':
points = {}
raw_points = spec.get('points') or {}
if not isinstance(raw_points, dict):
return None
for name, value in list(raw_points.items())[:MAX_POINTS]:
if isinstance(name, str) and _NAME_OK.match(name):
try:
points[name] = _point_def(value)
except (ValueError, KeyError, TypeError):
continue
items = []
for item in (spec.get('items') or [])[:MAX_ITEMS]:
if isinstance(item, dict):
try:
clean = _plane_item(item)
except (ValueError, KeyError, TypeError):
continue
items.append(clean)
if not items and not points:
return None
# بی‌محور مگر مدل صریحاً بخواهد یا نمودارِ تابع باشد: مثلثی که محور
# گرفته بود عددهای محور را روی برچسبِ ضلع‌ها می‌انداخت (۲۰۲۶-۰۹-۲۸).
axes = spec.get('axes')
if not isinstance(axes, bool):
axes = any(i['type'] == 'function' for i in items)
return {'kind': 'plane', 'title': title, 'axes': axes,
'points': points, 'items': items}
return _plane(spec, title)
if kind == 'numberline':
lo, hi = _num(spec.get('min', -5)), _num(spec.get('max', 5))
if not lo < hi or hi - lo > 200:
return None
marks = []
for p in (spec.get('points') or [])[:MAX_POINTS]:
try:
marks.append({'at': _num(p['at']), 'label': _label(p.get('label'))})
except (ValueError, KeyError, TypeError):
continue
jumps = []
for j in (spec.get('jumps') or [])[:MAX_ITEMS]:
try:
jumps.append({'from': _num(j['from']), 'to': _num(j['to']), 'label': _label(j.get('label'))})
except (ValueError, KeyError, TypeError):
continue
return {'kind': 'numberline', 'title': title, 'min': lo, 'max': hi,
'points': marks, 'jumps': jumps}
return _numberline(spec, title)
if kind == 'fraction':
parts = int(_num(spec.get('parts'), 1, 24))
shaded = int(_num(spec.get('shaded', 0), 0, parts))
shape = spec.get('shape') if spec.get('shape') in ('circle', 'rect') else 'circle'
return {'kind': 'fraction', 'title': title, 'shape': shape, 'parts': parts, 'shaded': shaded}
except (ValueError, KeyError, TypeError):
compare = []
for f in (spec.get('compare') or [])[:4]:
try:
p = _int(f['parts'], 1, 24)
clean = {'parts': p, 'shaded': _clamp(f.get('shaded', 0), 0, p)}
if f.get('label'):
clean['label'] = _label(f['label'], 16)
compare.append(clean)
except Invalid:
continue
if compare:
return {'kind': 'fraction', 'title': title, 'shape': 'rect', 'parts': compare[0]['parts'],
'shaded': compare[0]['shaded'], 'compare': compare}
parts = _int(spec.get('parts'), 1, 24)
shaded = _clamp(spec.get('shaded', 0), 0, parts)
out = {'kind': 'fraction', 'title': title, 'shape': shape, 'parts': parts, 'shaded': shaded}
if spec.get('wholes'):
out['wholes'] = _clamp(spec['wholes'], 0, 5)
return out
if kind == 'grid':
rows, cols = _int(spec.get('rows'), 1, 20), _int(spec.get('cols'), 1, 20)
shaded = spec.get('shaded', 0)
if isinstance(shaded, list):
cells = []
for cell in shaded[:400]:
try:
r, c = _pair(cell, lambda v: _int(v, 0, 19))
if r < rows and c < cols:
cells.append([r, c])
except Invalid:
continue
shaded = cells
else:
shaded = _clamp(shaded, 0, rows * cols)
out = {'kind': 'grid', 'title': title, 'rows': rows, 'cols': cols, 'shaded': shaded}
if spec.get('color') in COLORS:
out['color'] = spec['color']
return out
if kind == 'groups':
out = {'kind': 'groups', 'title': title, 'groups': _int(spec.get('groups'), 1, 10), 'each': _int(spec.get('each'), 1, 12)}
if spec.get('item') in ('circle', 'square', 'star'):
out['item'] = spec['item']
if spec.get('label'):
out['label'] = _label(spec['label'])
return out
if kind == 'clock':
return {'kind': 'clock', 'title': title, 'hour': _int(spec.get('hour'), 0, 24), 'minute': _int(spec.get('minute', 0), 0, 59)}
if kind in ('bar', 'linechart', 'pie'):
labels = _strings(spec.get('labels'), 12, 16)
values = [_num(v, -1e6, 1e6) for v in (spec.get('values') or [])[:12]]
n = min(len(labels), len(values))
if not n or (kind == 'pie' and sum(v for v in values[:n] if v > 0) <= 0):
return None
out = {'kind': kind, 'title': title, 'labels': labels[:n], 'values': values[:n]}
if spec.get('unit'):
out['unit'] = _label(spec['unit'], 12)
return out
if kind == 'table':
headers = _strings(spec.get('headers') or [], 10, 16)
rows = [_strings(r, 10, 24) for r in (spec.get('rows') or [])[:12] if isinstance(r, list)]
if not rows and not headers:
return None
return {'kind': 'table', 'title': title, 'headers': headers, 'rows': rows}
if kind == 'venn':
sets = [s for s in _strings(spec.get('sets'), 3, 1) if re.fullmatch(r'[A-Z]', s)]
if not sets or len(set(sets)) != len(sets):
return None
regions = {}
raw = spec.get('regions') or {}
if isinstance(raw, dict):
for code, members in list(raw.items())[:8]:
if isinstance(code, str) and (code == 'out' or (code and set(code) <= set(sets))) and isinstance(members, list):
regions[code] = _strings(members, 12, 8)
shade = [c for c in (spec.get('shade') or []) if isinstance(c, str) and (c == 'out' or (c and set(c) <= set(sets)))][:8]
out = {'kind': 'venn', 'title': title, 'sets': sets, 'regions': regions, 'shade': shade}
names = spec.get('names')
if isinstance(names, list) and len(names) == len(sets):
out['names'] = [_label(n, 12) for n in names]
return out
if kind == 'mapping':
left, right = _strings(spec.get('left'), 10, 10), _strings(spec.get('right'), 10, 10)
arrows = []
for a in (spec.get('arrows') or [])[:20]:
try:
i, j = _pair(a, lambda v: _int(v, 0, 9))
if i < len(left) and j < len(right):
arrows.append([i, j])
except Invalid:
continue
if not left or not right:
return None
out = {'kind': 'mapping', 'title': title, 'left': left, 'right': right, 'arrows': arrows}
for key in ('leftLabel', 'rightLabel'):
if spec.get(key):
out[key] = _label(spec[key], 16)
return out
if kind == 'tree':
return {'kind': 'tree', 'title': title, 'root': _tree(spec.get('root'))}
if kind == 'solid':
shape = spec.get('shape')
if shape not in _SOLIDS:
return None
dims = {}
for key, (lo, hi) in {'a': (0.01, 1e4), 'b': (0.01, 1e4), 'c': (0.01, 1e4), 'r': (0.01, 1e4), 'h': (0.01, 1e4), 'n': (3, 8)}.items():
try:
if (spec.get('dims') or {}).get(key) is not None:
dims[key] = _num(spec['dims'][key], lo, hi)
except Invalid:
continue
labels = {}
raw_labels = spec.get('labels') or {}
if isinstance(raw_labels, dict):
for key in ('a', 'b', 'c', 'r', 'h'):
if raw_labels.get(key):
labels[key] = _label(raw_labels[key], 12)
return {'kind': 'solid', 'title': title, 'shape': shape, 'dims': dims, 'labels': labels}
except Invalid:
return None
return None
@@ -249,13 +671,80 @@ def extract(text: str) -> tuple[str, dict | None]:
return _BLOCK.sub('', text).rstrip(), spec
_DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
# مقدارِ عددیِ نوشته‌شده به‌شکلِ عبارت، که JSON نیست: -2*pi، pi/2، 3pi/4
_PI_VALUE = re.compile(r'(?<=[:\[,])(\s*)(-?)\s*(\d*\.?\d*)\s*\*?\s*pi(?:\s*/\s*(\d+(?:\.\d+)?))?(?=\s*[,\]}])')
def _repair(body: str) -> str:
"""
خطاهای قالبیِ واقعیِ gpt-4.1-mini (سنجشِ ۲۰۲۶-۰۹-۲۸، ۴۸ مسئله‌ی کتاب) که معنا را
عوض نمی‌کنند: رقمِ فارسی در عدد («"rows":۱۰») و π به‌صورتِ عبارت («"from":-2*pi»).
رقم‌های داخلِ برچسب هم لاتین می‌شوند؛ فرانت دوباره فارسی‌شان می‌کند.
"""
body = body.translate(_DIGITS)
# توضیحِ «// …» بعد از یک مقدار (مدل برای خودش یادداشت گذاشته بود)
body = re.sub(r'(?<=[,\[{\]}"\d])[ \t]*//[^\n]*', '', body)
def pi(m):
sign, coef, den = m.group(2), m.group(3), m.group(4)
value = (float(coef) if coef else 1.0) * math.pi / (float(den) if den else 1.0)
return f'{m.group(1)}{"-" if sign else ""}{round(value, 6)}'
return _PI_VALUE.sub(pi, body)
def _normalize(obj):
"""
شکل‌هایی که مدل با کمی فاصله از قالب نوشت ولی منظورش روشن است:
- یک item تنها (با "type") بدونِ plane دورش؛
- "kind":"function" با فیلدهای همان item؛
- نمودارِ ون با نامِ فارسیِ مجموعه‌ها («والیبال») به‌جای A و B.
"""
if not isinstance(obj, dict):
return obj
kind = obj.get('kind')
if kind is None and 'type' in obj:
return {'kind': 'plane', 'title': obj.get('title', ''), 'points': {}, 'items': [obj]}
if kind in ('function', 'piecewise') and 'expr' in obj or kind == 'function' and 'pieces' in obj:
item = {k: v for k, v in obj.items() if k not in ('kind', 'title', 'axes', 'grid', 'trig', 'xRange', 'yRange', 'points')}
item['type'] = 'piecewise' if 'pieces' in obj else 'function'
plane = {'kind': 'plane', 'title': obj.get('title', ''), 'axes': True, 'points': obj.get('points') or {}, 'items': [item]}
for key in ('grid', 'trig', 'xRange', 'yRange'):
if key in obj:
plane[key] = obj[key]
return plane
if kind == 'venn' and isinstance(obj.get('sets'), list):
names = [str(n) for n in obj['sets'][:3]]
if names and not all(re.fullmatch(r'[A-Z]', n) for n in names):
letters = 'ABC'[:len(names)]
index = {n: letters[i] for i, n in enumerate(names)}
def code(key: str) -> str | None:
if key == 'out':
return 'out'
parts = [p for p in re.split(r'\s*[_∩&,،+]\s*|\s+و\s+', key) if p]
if all(p in index for p in parts):
return ''.join(sorted(index[p] for p in parts))
return key if set(key) <= set(letters) else None
regions = {}
for key, members in (obj.get('regions') or {}).items():
c = code(str(key))
if c:
regions[c] = members
obj = {**obj, 'sets': list(letters), 'names': names, 'regions': regions,
'shade': [c for c in (code(str(k)) for k in obj.get('shade') or []) if c]}
return obj
def parse(body: str) -> dict | None:
body = (body or '').strip()
if body.startswith('```'):
body = body.strip('`').removeprefix('json').strip()
try:
return validate(json.loads(body))
except (json.JSONDecodeError, TypeError):
return validate(_normalize(json.loads(_repair(body))))
except (json.JSONDecodeError, TypeError, ValueError):
logger.info('diagram: JSONِ نامعتبر از مدل، دور ریخته شد')
return None

View File

@@ -48,8 +48,9 @@ class ValidateTests(SimpleTestCase):
self.assertEqual(list(spec['points']), ['B'])
def test_labels_are_truncated_and_fraction_shaded_is_capped(self):
# بیش از تعدادِ تکه‌ها محدود می‌شود، نه اینکه کلِ شکل حذف شود
spec = validate({'kind': 'fraction', 'parts': 4, 'shaded': 9, 'title': 'x' * 500})
self.assertIsNone(spec)
self.assertEqual(spec['shaded'], 4)
spec = validate({'kind': 'fraction', 'parts': 4, 'shaded': 3, 'title': 'x' * 500})
self.assertEqual((spec['parts'], spec['shaded'], len(spec['title'])), (4, 3, 40))
@@ -148,7 +149,7 @@ class ShapeHintTests(SimpleTestCase):
from .services.generation import _user_prompt
for q in ('وترِ مثلث با ضلع‌های ۳ و ۴؟', 'سه‌چهارم پیتزا', 'نمودار y=x²', 'sin 30°'):
self.assertEqual(hint_for(q), DIAGRAM_HINT, q)
self.assertTrue(hint_for(q).startswith(DIAGRAM_HINT), q)
self.assertIn(DIAGRAM_HINT, _user_prompt(q, []))
for q in ('۲۳ × ۴۵ چند می‌شود؟', 'عبارت (x+2)(x-3) را ساده کن.'):
self.assertEqual(hint_for(q), '', q)
@@ -169,3 +170,153 @@ class AxesDefaultTests(SimpleTestCase):
graph = validate({'kind': 'plane', 'items': [{'type': 'function', 'expr': 'x^2', 'from': -2, 'to': 2}]})
self.assertTrue(graph['axes'])
self.assertTrue(validate({**CIRCLE})['axes']) # صریحاً true خواسته شده
class SectionedSpecTests(SimpleTestCase):
"""کنارِ هر سوال فقط بخشِ مربوط به همان سوال از کاتالوگ می‌رود (هزینه‌ی توکن)."""
def test_only_matching_sections_are_sent(self):
from .services.diagram import _GRAPH, _PLANE, _SOLID, _TREE, hint_for
solid = hint_for('حجمِ استوانه‌ای با شعاعِ ۳ و ارتفاعِ ۵ چقدر است؟')
self.assertIn(_SOLID, solid)
self.assertNotIn(_GRAPH, solid)
self.assertNotIn(_TREE, solid)
graph = hint_for('نمودارِ تابعِ y = x² − 4 را رسم کن')
self.assertIn(_GRAPH, graph)
self.assertIn(_PLANE, graph) # نمودار داخلِ plane است
self.assertNotIn(_SOLID, graph)
self.assertLess(len(solid), len(graph))
def test_bar_chart_does_not_pull_function_graphs(self):
from .services.diagram import _CHARTS, _GRAPH, hint_for
text = hint_for('نمودارِ ستونیِ آب‌میوه‌ها را رسم کن')
self.assertIn(_CHARTS, text)
self.assertNotIn(_GRAPH.split(':')[0], text.replace('رسم', ''))
class ExpandedKindsTests(SimpleTestCase):
"""نوع‌های تازه: هر کدام با یک نمونه‌ی واقعی و یک نمونه‌ی خراب."""
def test_constructed_points_and_new_items(self):
spec = validate({
'kind': 'plane', 'points': {
'A': [0, 0], 'B': [4, 0], 'C': [1, 3], 'M': {'mid': ['A', 'B']}, 'H': {'foot': ['C', 'A', 'B']},
'I': {'intersect': [['A', 'C'], ['B', [0, 3]]]}, 'T': {'along': ['A', 'B', 0.25]}, 'F': {'fx': ['x^2', 3]},
'X': {'mid': ['A']}, 'Y': {'fx': ['alert(1)', 2]},
},
'items': [
{'type': 'segment', 'from': 'A', 'to': 'M', 'ticks': 2, 'color': 'green'},
{'type': 'angle', 'vertex': 'A', 'from': 'B', 'to': 'C', 'arcs': 9},
{'type': 'image', 'of': ['A', 'B', 'C'], 'transform': {'rotate': {'center': 'C', 'deg': -90}}},
{'type': 'piecewise', 'pieces': [{'expr': 'x+1', 'from': -2, 'to': 1, 'closedTo': False}, {'expr': 'bad;', 'from': 1, 'to': 2}]},
{'type': 'segment', 'from': 'A', 'to': 'B', 'color': '#ff0000'},
],
})
self.assertEqual(set(spec['points']), {'A', 'B', 'C', 'M', 'H', 'I', 'T', 'F'})
self.assertEqual(spec['items'][1]['arcs'], 3)
self.assertEqual(spec['items'][2]['transform'], {'rotate': {'center': 'C', 'deg': -90.0}})
self.assertEqual(len(spec['items'][3]['pieces']), 1)
self.assertNotIn('color', spec['items'][4]) # فقط نامِ رنگِ شناخته‌شده
def test_solid_chart_venn_tree_mapping_grid_clock_table(self):
self.assertEqual(validate({'kind': 'solid', 'shape': 'cone', 'dims': {'r': 4, 'h': 12}, 'labels': {'r': '4 cm'}})['dims'], {'r': 4.0, 'h': 12.0})
self.assertIsNone(validate({'kind': 'solid', 'shape': 'teapot'}))
pie = validate({'kind': 'pie', 'labels': ['a', 'b', 'c'], 'values': [1, 2]})
self.assertEqual(pie['labels'], ['a', 'b'])
self.assertIsNone(validate({'kind': 'pie', 'labels': ['a'], 'values': [0]}))
venn = validate({'kind': 'venn', 'sets': ['A', 'B'], 'regions': {'AB': ['3'], 'AC': ['x'], 'out': ['6']}, 'shade': ['AB', 'Z']})
self.assertEqual(set(venn['regions']), {'AB', 'out'})
self.assertEqual(venn['shade'], ['AB'])
tree = validate({'kind': 'tree', 'root': {'label': 'شروع', 'children': [{'label': 'رو', 'p': '1/2', 'children': [{'label': 'a', 'children': [{'label': 'b', 'children': [{'label': 'too deep'}]}]}]}]}})
leaf = tree['root']['children'][0]['children'][0]['children'][0]
self.assertNotIn('children', leaf) # حداکثر سه سطح زیرِ ریشه
m = validate({'kind': 'mapping', 'left': ['1', '2'], 'right': ['3'], 'arrows': [[0, 0], [1, 5]]})
self.assertEqual(m['arrows'], [[0, 0]])
self.assertEqual(validate({'kind': 'grid', 'rows': 5, 'cols': 5, 'shaded': 99})['shaded'], 25)
self.assertEqual(validate({'kind': 'clock', 'hour': 3, 'minute': 25})['minute'], 25)
self.assertEqual(validate({'kind': 'table', 'headers': ['x', '1'], 'rows': [['y', '+'], 'bad']})['rows'], [['y', '+']])
def test_numberline_intervals_and_fraction_step(self):
nl = validate({'kind': 'numberline', 'min': 0, 'max': 2, 'step': 0.25, 'fractions': True,
'intervals': [{'from': -2, 'to': 3, 'closedFrom': True}, {'from': 1, 'to': None}, {'from': 3, 'to': 1}]})
self.assertEqual(nl['step'], 0.25)
self.assertEqual(nl['intervals'], [{'from': -2.0, 'to': 3.0, 'closedFrom': True}, {'from': 1.0, 'to': None}])
self.assertNotIn('step', validate({'kind': 'numberline', 'min': 0, 'max': 100, 'step': 0.01}))
class ModelSlipRepairTests(SimpleTestCase):
"""خطاهای قالبیِ واقعیِ gpt-4.1-mini در سنجشِ ۲۰۲۶-۰۹-۲۸؛ عیناً همان خروجی‌ها."""
def test_persian_digits_in_json_numbers(self):
from .services.diagram import parse
spec = parse('{"kind":"grid","title":"نمایش ۱۸ درصد","rows":۱۰,"cols":۱۰,"shaded":۱۸}')
self.assertEqual((spec['rows'], spec['cols'], spec['shaded']), (10, 10, 18))
def test_pi_expressions_as_numbers(self):
from .services.diagram import parse
spec = parse('{"kind":"plane","title":"t","axes":true,"items":[{"type":"function","expr":"sin(x)^2","from":-2*pi,"to":2*pi},'
'{"type":"vline","x":pi/2},{"type":"vline","x":3pi/4}]}')
self.assertAlmostEqual(spec['items'][0]['from'], -6.283185, places=5)
self.assertAlmostEqual(spec['items'][1]['x'], 1.570796, places=5)
self.assertAlmostEqual(spec['items'][2]['x'], 2.356194, places=5)
def test_bare_item_and_function_kind_become_a_plane(self):
from .services.diagram import parse
bare = parse('{"type":"function","expr":"sqrt(3*x - 5)","from":1.6,"to":5,"label":"f(x) = √(3x - 5)","marks":["yIntercept"]}')
self.assertEqual(bare['kind'], 'plane')
self.assertTrue(bare['axes'])
self.assertEqual(bare['items'][0]['expr'], 'sqrt(3*x - 5)')
kinded = parse('{"kind":"function","title":"نمودار تابع p(x)","axes":true,"xRange":[-3,3],"yRange":[-10,10],'
'"type":"function","expr":"x^3 - x^2 - 4*x + 4","from":-3,"to":3,"marks":["roots"]}')
self.assertEqual(kinded['kind'], 'plane')
self.assertEqual(kinded['xRange'], [-3.0, 3.0])
self.assertEqual(kinded['items'][0]['marks'], ['roots'])
def test_venn_with_persian_set_names(self):
from .services.diagram import parse
spec = parse('{"kind":"venn","title":"ون","sets":["والیبال","فوتبال"],"regions":{"والیبال":["7-x"],'
'"والیبال_فوتبال":["x"],"فوتبال":["4-x"],"out":[]},"shade":["والیبال_فوتبال"]}')
self.assertEqual(spec['sets'], ['A', 'B'])
self.assertEqual(spec['names'], ['والیبال', 'فوتبال'])
self.assertEqual(spec['regions'], {'A': ['7-x'], 'AB': ['x'], 'B': ['4-x'], 'out': []})
self.assertEqual(spec['shade'], ['AB'])
def test_unknown_kind_is_still_dropped(self):
from .services.diagram import parse
self.assertIsNone(parse('{"kind":"line_segment","points":[{"label":"مشهد","position":0}]}'))
def test_comments_after_values_and_wide_numberline(self):
from .services.diagram import parse
spec = parse('{"kind":"venn","sets":["A","B","C"],"regions":{"AC":["5"]},"shade":[\n "AC", // الف\n "B" // ب\n]}')
self.assertEqual(spec['shade'], ['AC', 'B'])
wide = parse('{"kind":"numberline","min":-۱۴۰,"max":۸۷,"step":۱۰,"points":[{"at":-۵۵,"label":"-۵۵"}]}')
self.assertEqual((wide['min'], wide['max'], wide['step']), (-140.0, 87.0, 10.0))
class SecondRoundKindsTests(SimpleTestCase):
"""افزوده‌های دورِ دومِ سنجش: خط از معادله، مقایسه‌ی کسرها، عددِ مخلوط، محرکِ ساعت."""
def test_line_from_equation(self):
spec = validate({'kind': 'plane', 'items': [{'type': 'line', 'eq': [3, 5, 4]}, {'type': 'line', 'eq': [0, 0, 1]}]})
self.assertEqual(spec['items'], [{'type': 'line', 'eq': [3.0, 5.0, 4.0]}])
def test_fraction_compare_and_wholes(self):
cmp = validate({'kind': 'fraction', 'compare': [{'parts': 5, 'shaded': 1, 'label': 'یک‌پنجم'}, {'parts': 10, 'shaded': 12}]})
self.assertEqual(cmp['compare'][1], {'parts': 10, 'shaded': 10})
mixed = validate({'kind': 'fraction', 'parts': 10, 'shaded': 7, 'wholes': 2})
self.assertEqual((mixed['wholes'], mixed['shaded']), (2, 7))
def test_clock_only_for_telling_time(self):
from .services.diagram import _CLOCK, hint_for
self.assertNotIn(_CLOCK, hint_for('فاطمه ۶ ساعت کتاب خواند. چند صفحه ماند؟'))
self.assertIn(_CLOCK, hint_for('عقربه‌ی ساعت‌شمار روی ۳ است. ساعت چند است؟'))
self.assertIn(_CLOCK, hint_for('ساعت ۳ و ۲۵ دقیقه را روی ساعت نشان بده'))