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

@@ -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