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:
32
CLAUDE.md
32
CLAUDE.md
@@ -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/گزارش-۲۵/`.
|
||||
|
||||
|
||||
@@ -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,11 +156,23 @@ 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'])
|
||||
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()
|
||||
],
|
||||
}
|
||||
|
||||
175
content/management/commands/fill_lesson_texts.py
Normal file
175
content/management/commands/fill_lesson_texts.py
Normal 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
93
content/tests_lesson_texts.py
Normal file
93
content/tests_lesson_texts.py
Normal 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, 'ادمین')
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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('ساعت ۳ و ۲۵ دقیقه را روی ساعت نشان بده'))
|
||||
|
||||
Reference in New Issue
Block a user