""" شکلِ پاسخِ دستیار: مدل یک توصیفِ JSONِ کوتاه در … می‌نویسد و اپ خودش آن را به SVG تبدیل می‌کند (MathApp_frontend، src/diagram). چرا مدل خودش SVG/HTML نمی‌نویسد (سنجیده ۲۰۲۶-۰۹-۲۸ روی gpt-4.1-mini): - **امنیت:** HTMLِ مدل (که متنِ کتاب و سوالِ کاربر را هم خوانده) اگر مستقیم در صفحه بنشیند، راهِ تزریقِ اسکریپت است. JSONِ اعتبارسنجی‌شده فقط داده است. - **درستیِ هندسه:** مدل در حسابِ مختصات (cos 30° و …) خطا می‌کند؛ اینجا فقط می‌گوید «نقطه روی دایره با زاویه‌ی ۳۰°»، «وسطِ AB»، «ریشه‌های تابع» و فرانت همه را دقیق حساب می‌کند. - **هزینه و سرعت:** یک JSONِ ۱۰۰–۳۰۰ توکنی به‌جای SVGِ چندصدتوکنی. - Structured Outputs (json_schema) را این مدل دارد ولی کلِ پاسخ را JSON می‌کند و استریمِ متن را خراب می‌کند؛ برای همین فقط بلوکِ آخر JSON است. دو تکه‌ی پرامپت: `DIAGRAM_RULE` کوتاه است و همیشه در پرامپتِ سیستم می‌آید؛ `DIAGRAM_SPEC` (کاتالوگِ کاملِ نوع‌ها) فقط کنارِ سوالی می‌آید که کلمه‌ی شکل‌دار دارد (`hint_for`)، تا سوالِ حسابیِ ساده هزینه‌ی توکنِ اضافه نداشته باشد. این ماژول قاعده‌ها را می‌دهد، بلوک را از متن جدا می‌کند (قبل از `mathtext`، که JSON را خراب می‌کرد) و سخت‌گیرانه اعتبارسنجی می‌کند: فقط کلید و نوع و رنگِ شناخته‌شده، عددِ متناهی و محدود، متنِ کوتاه. تکه‌ی نامعتبر بی‌صدا حذف می‌شود؛ متنِ پاسخ هیچ‌وقت به‌خاطرِ شکل از دست نمی‌رود. آزموده نشده: رفتارِ مدل‌های دیگر جز gpt-4.1-mini. """ import ast import json import logging import math import re logger = logging.getLogger(__name__) OPEN, CLOSE = '', '' _BLOCK = re.compile(r'(.*?)(?:|$)', re.S) DIAGRAM_RULE = ( 'شکل (اپ این را به تصویر تبدیل می‌کند):\n' '- اگر سوال شکل دارد (هندسه، حجم، محورِ اعداد، نمودارِ تابع، نمودارِ آماری، کسر، ' 'مجموعه، احتمال، ساعت)، در پایانِ پاسخ دقیقاً یک بلوکِ … بگذار که ' 'داخلش فقط یک JSON است، نه SVG و نه HTML. قالبِ دقیقش کنارِ همان سوال می‌آید.\n' '- برای حسابِ عددی یا ساده‌کردنِ عبارتِ جبری که شکلی ندارد، شکل نگذار.' ) # کاتالوگِ نوع‌ها، بخش‌بخش. کنارِ هر سوال فقط بخش‌هایی می‌رود که کلمه‌هایش در سوال # هست: کاتالوگِ کامل ۱٬۸۵۴ توکن بود (سنجیده با خودِ API، ۲۰۲۶-۰۹-۲۸) و هزینه‌ی هر # سوالِ شکل‌دار را تقریباً دو برابر می‌کرد. _HEAD = ( 'قالبِ بلوکِ : فقط یک JSON، کلیدها انگلیسی، برچسب‌ها فارسی یا ریاضی. هر شکل "kind" و ' '"title"ِ کوتاهِ فارسی دارد. مختصاتی را که قابلِ حساب است خودت حساب نکن؛ بگو «چه»، اپ «کجا» را ' 'دقیق حساب می‌کند. رنگ فقط: blue red green orange purple teal pink gray black yellow. ' 'در متنِ پاسخ به شکل اشاره کن («در شکلِ پایین…») با همان نام‌ها. یک شکلِ روشن بهتر از شکلِ شلوغ. ' 'شکلی که به فهمیدن کمک نمی‌کند (تبدیلِ واحد، ضرب یا تقسیمِ ساده، مدتِ زمان) نگذار.' ) _PLANE = ( 'مثلث و چهارضلعی (مستطیل، مربع، متوازی‌الاضلاع، لوزی، ذوزنقه) را همیشه با triangle و quad بکش و مختصاتِ رأس‌هایشان را ' 'هرگز خودت ننویس؛ polygon فقط وقتی مسئله خودش مختصاتِ رأس‌ها را داده است.\n' '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","equation":"3x + 5y = 4"} خط از روی معادله، عیناً همان‌طور که در مسئله یا جوابت نوشته‌ای ' '(«y = 12x − 16»، «x = 2.5» هم درست است؛ خودت شیب و نقطه حساب نکن) · ' '{"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 ۱ تا ۳ برای زاویه‌های مساوی) · ' '{"type":"rightAngle","vertex":"H","from":"A","to":"B"} · {"type":"polygon","points":["A","B","C"]} · ' '{"type":"triangle","names":["A","B","C"],"sides":{"AB":6,"AC":8},"angles":{"A":90},"labels":{"BC":"x"},"altitude":"C"} ' '(مثلث از روی داده‌های مسئله: هر ضلع و زاویه‌ای که مسئله داده، به همان عدد؛ اپ رأس‌ها را با قانونِ سینوس و کسینوس ' 'می‌سازد؛ labels برای ضلع یا زاویه‌ی مجهول («x»، «?»)؛ altitude ارتفاع از آن رأس با پای H، labels.h برچسبش) · ' '{"type":"quad","shape":"rectangle","names":["A","B","C","D"],"w":8,"h":5} (چهارضلعی: rectangle با w و h؛ square با ' 'side؛ parallelogram با w و side یا h و angle؛ rhombus با d1 و d2 (یا side و angle)؛ trapezoid با w قاعده‌ی پایین، top ' 'قاعده‌ی بالا، h ارتفاع، "right":true برای قائم؛ labels مثلِ مثلث). نام‌ها: مستطیل rectangle، مربع square، ' 'متوازی‌الاضلاع parallelogram، لوزی rhombus، ذوزنقه trapezoid. جای شکل: اگر دو رأسِ اولش از قبل مختصات دارند، روی همان ضلع ' 'ساخته می‌شود؛ چند شکلِ جدا خودکار کنارِ هم می‌نشینند؛ "at":[x,y] جای رأسِ اول. بردار یا انتقال: ' '"B":{"translate":[2,3],"from":"A"} · ' '{"type":"regularPolygon","n":5,"r":2,"names":["A","B","C","D","E"]} (چندضلعیِ منتظم؛ رأس‌ها را اپ می‌سازد و ' 'با همین نام‌ها قابلِ ارجاع‌اند؛ مختصاتِ رأسِ چندضلعیِ منتظم را هرگز خودت ننویس) · ' '{"type":"exteriorAngle","vertex":"B","from":"A","to":"C","label":"72°"} (زاویه‌ی خارجیِ رأسِ B: اپ ضلعِ AB را از B ' 'امتداد می‌دهد و زاویه‌ی بینِ امتداد و BC را می‌کشد) · ' 'برای دوران، تقارن، انتقال و تجانس همیشه هم شکلِ اصلی و هم تصویرش را بکش (با image؛ مختصاتِ تصویر را خودت ننویس) · ' '{"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" اختیاری). ' 'هر نمودار با item نوعِ function کشیده می‌شود؛ ریشه، رأس و اکسترمم را با marks بخواه، نه با نقطه‌ی دستی. ' 'خطِ مماس همیشه با tangent: ' '{"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"}. ' 'کسری از یک کل («نیمی از زمین»، «یک‌سوم از کیک») با fraction یا grid است، نه groups. ' 'کسرِ جبری (با x یا a) و ساده کردنِ عبارت شکل ندارد.' ) _PATTERN = ( 'pattern (الگوی شکلی): {"kind":"pattern","title":"…","layout":"square","unit":"square","stages":[1,2,3],"next":true} ' '(stages عددِ هر شکل: layout=row یعنی n تا در یک ردیف، square یعنی n×n، rect یعنی n×(n+1)، triangle مثلثی (۱، ۳، ۶)، ' 'stair پلکانی، L؛ unit=square یا dot، یا stick برای n مربعِ چوب‌کبریتی در یک ردیف؛ next=true جای «شکلِ بعدی؟» را ' 'می‌گذارد). دنباله‌ی عددیِ بی‌شکل (۳، ۷، ۱۱، …) شکل ندارد.' ) _CLOCK = ( 'clock: {"kind":"clock","title":"…","hour":3,"minute":25} فقط برای خواندن یا نشان‌دادنِ ساعت روی صفحه‌ی ساعت؛ ' 'نه برای مدت («۶ ساعت»)، کسر یا درصدی از یک ساعت، یا عقربه‌ی چرخنده (فرفره) که pie است' ) _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؛ تا سه مجموعه؛ هر عضو فقط یک بار، و عضوِ ' 'مشترک فقط در AB؛ پیش از نوشتن، عضویتِ هر عدد را در هر مجموعه بسنج) · ' '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":[…]},…]}} (حداکثر سه سطح) · چرخنده‌ی رنگی (فرفره): {"kind":"pie","title":"…","labels":["قرمز","آبی"],' '"values":[3,1]} با سهمِ هر رنگ' ) _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 فقط برای قاعده‌ی ' 'مثلث، پنج‌ضلعی و شش‌ضلعی)' ) _SPACE = ( 'space (مختصات و بردار در فضای سه‌بعدی، تصویرِ مایل؛ برای R³، بردارِ سه‌مؤلفه‌ای، ضربِ داخلی و خارجی، خط و صفحه در فضا): ' '{"kind":"space","title":"…","axes":true,"points":{"A":[1,2,3],"B":[3,0,1]},"items":[…]}. محورها: x رو به بیننده، ' 'y راست، z بالا. items: {"type":"point","point":"A","label":"A(1,2,3)"} · {"type":"vector","from":"O","to":"A","label":"a"} · ' '{"type":"segment","from":"A","to":"B","dashed":true} · {"type":"polygon","points":["A","B","C"],"label":"P"} (مثلث یا صفحه‌ی ' 'متوازی‌الاضلاعی؛ نیمه‌شفاف) · {"type":"box","to":[3,2,4]} (مکعب‌مستطیلِ مختصاتی از مبدأ) · {"type":"drop","point":"A"} ' '(خط‌چین‌های تصویرِ نقطه روی صفحه‌های مختصات). مبدأ "O" خودکار است. رنگ فقط: blue red green orange purple teal.' ) _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'اتاق', 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'(?:یک|دو|سه|چهار|پنج|شش|هفت|هشت|نه|نُه|ده)[\u200c\s]?(?:دوم|سوم|چهارم|پنجم|ششم|هفتم|هشتم|نهم|دهم)(?!ین)')), _PARTS), # «۶ ساعت مطالعه» مدت است، نه ساعتِ دیواری؛ فقط عقربه یا «ساعت … و … دقیقه» (_W((r'عقربه', r'ساعت\s*[0-9۰-۹]+\s*(?::|و)\s*[0-9۰-۹]+', r'ساعت\s*چند(?!\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'چوب\s*کبریت', r'دنباله', r'شکل[\u0650\s]*(?:بعدی|شماره|[0-9۰-۹])')), _PATTERN), (_W((r'مکعب', r'منشور', r'هرم', r'استوانه', r'مخروط', r'کره', r'حجم', r'نیم‌کره')), _SOLID), (_W((r'سه‌بعدی', r'R³', r'ضرب\s*(?:داخلی|خارجی)', r'مختصات[^.؟]{0,12}فضا', r'بردار[^.؟]{0,20}فضا')), _SPACE), ] DIAGRAM_SPEC = '\n'.join([_HEAD] + [text for _, text in SECTIONS]) DIAGRAM_HINT = ( 'این سوال شکل دارد: در پایانِ پاسخ بلوکِ … را طبقِ این قالب بگذار.' ) FOLLOW_UP_HINT = ( 'اگر این سوال ادامه‌ی همان مسئله‌ی شکل‌دارِ قبلی است (عدد یا اندازه عوض شده، یا توضیحِ بیشتر ' 'خواسته)، در پایانِ پاسخ شکلِ تازه را با عددهای تازه در بلوکِ … بکش؛ ' 'اگر نیست، شکل نگذار.' ) def follow_up_hint_for(question: str, previous: str) -> str: """ سوالِ ادامه‌ای بی‌کلمه‌ی شکل‌دار («اگه به‌جای ۶ بذاریم ۴ چی؟») بعد از یک سوالِ شکل‌دار: یادآوریِ نرم (نه «این سوال شکل دارد») و بخش‌های کاتالوگِ همان سوالِ قبلی. نرم است چون «مرسی» بعد از یک سوالِ هندسه نباید شکل بگیرد. """ if not previous or not (question or '').strip(): return '' parts = [section for pattern, section in SECTIONS if pattern.search(previous)] if not parts: return '' return '\n'.join([FOLLOW_UP_HINT, _HEAD, *parts]) # «شکلش رو بکش» بی‌هیچ کلمه‌ی موضوعی (و بی‌سوالِ شکل‌دارِ قبلی): نمی‌دانیم کدام بخش، # پس کلِ کاتالوگ می‌رود (~۱٬۹۰۰ توکن). نادر است، چون بیشترِ درخواست‌ها کلمه‌ی موضوعی دارند. # مرزِ پیش از کلمه لازم است: «مشکل» و «بپرسم» نباید درخواستِ شکل شمرده شوند. _ASK_FIGURE = re.compile(r'(?… را ' 'طبقِ این قالب بگذار؛ اگر ندارد، کوتاه بگو که این مسئله شکل ندارد.' ) def asked_hint_for(question: str) -> str: """درخواستِ صریحِ شکل وقتی هیچ بخشی از کاتالوگ با سوال (یا سوالِ قبلی) نخورد.""" if not _ASK_FIGURE.search(question or ''): return '' return '\n'.join([ASKED_HINT, DIAGRAM_SPEC]) _CLOCK_TRIGGER = next(pattern for pattern, section in SECTIONS if section is _CLOCK) def fits(question: str, spec: dict | None) -> dict | None: """ نگهبانِ معنا پس از وارسیِ قالب: ساعتِ دیواری فقط وقتی سوال واقعاً درباره‌ی خواندنِ ساعت است. سنجشِ ۲۰۲۶-۰۹-۲۹: مدل برای «۲۰ درصدِ یک ساعت» و «عقربه‌ی فرفره» ساعت کشید؛ این دو خطا گمراه‌کننده‌اند (بچه فکر می‌کند جواب «۱۲:۱۲» است) و بی‌شکل بودن بهتر است. """ if spec and spec.get('kind') == 'clock' and not _CLOCK_TRIGGER.search(question or ''): logger.info('diagram: ساعت برای سوالِ بی‌ساعت، دور ریخته شد') return None return spec def hint_for(question: str) -> str: """یادآوری و فقط آن بخش‌هایی از کاتالوگ که به همین سوال می‌خورند؛ سوالِ بی‌شکل → ''.""" 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 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): if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError('not a number') if not math.isfinite(value) or not lo <= value <= hi: raise ValueError('out of range') return round(float(value), 6) 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()[:limit] def _xy(value): if not (isinstance(value, (list, tuple)) and len(value) == 2): raise ValueError('xy') return [_num(value[0]), _num(value[1])] def _ref(value): """نامِ نقطه یا مختصاتِ مستقیم.""" if isinstance(value, str): if not _NAME_OK.match(value): raise ValueError('ref name') return 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() _LINEAR_OK = re.compile(r'^[0-9xy+\-=.\s/]*$') def _linear_equation(value) -> str: """«3x + 5y = 4»، «y = 12x − 16»: فقط x، y، عدد، + − = و ممیز؛ خواندنِ دقیقش با فرانت است.""" if not isinstance(value, str): raise ValueError('equation') text = value.translate(_DIGITS).replace('−', '-').replace('–', '-').strip() if not text or len(text) > 60 or text.count('=') != 1 or not _LINEAR_OK.match(text) or not re.search('[xy]', text): raise ValueError('equation chars') return text 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 'translate' in value and value.get('from') is not None: return {'translate': _xy(value['translate']), 'from': _ref(value['from'])} 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 _WRAPPED_ITEMS = {'triangle', 'quad', 'regularPolygon', 'polygon', 'circle', 'segment', 'angle', 'exteriorAngle'} _LAYOUTS = {'row', 'square', 'rect', 'triangle', 'stair', 'L'} _QUADS = {'rectangle', 'square', 'parallelogram', 'rhombus', 'trapezoid'} def _quad_aliases(item: dict) -> dict: """نام‌های دیگری که مدل برای اندازه‌ها نوشت: bases، height، width، length، base.""" item = dict(item) bases = item.pop('bases', None) if isinstance(bases, list) and bases: item.setdefault('w', bases[0]) if len(bases) > 1: item.setdefault('top', bases[1]) rect = item.get('shape') in ('rectangle', None) for alias, key in (('height', 'h'), ('base', 'w'), ('length', 'w'), ('width', 'h' if rect else 'w')): if alias in item: item.setdefault(key, item.pop(alias)) return item def _names(value, n: int) -> list[str]: if not (isinstance(value, list) and len(value) == n and all(isinstance(v, str) and _NAME_OK.match(v) for v in value) and len(set(value)) == n): raise ValueError('names') return value def _side(key, names) -> bool: """«AB» یا «BA»: دو رأسِ متفاوت از همین شکل.""" return isinstance(key, str) and any(key == a + b for a in names for b in names if a != b) def _shape_labels(item: dict, out: dict, names: list[str], extra=('h',)): labels = {} for key, value in list((item.get('labels') or {}).items())[:8]: if key in names or key in extra or _side(key, names): text = _label(value, 12) if text: labels[key] = text if labels: out['labels'] = labels def _plane_item(item: dict) -> dict: kind = item.get('type') 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': if item.get('equation') is not None: out['equation'] = _linear_equation(item['equation']) elif item.get('eq') is not None: # ax + by + c = 0 — همان صورتی که gpt-4.1-mini هر سه بار نوشت (سنجشِ ۲۰۲۶-۰۹-۲۹)؛ # قراردادِ قبلیِ ax + by = c علامتِ c را برعکس می‌کرد و خط جابه‌جا می‌افتاد. 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'])}) if kind == 'angle' and item.get('arcs') is not None: out['arcs'] = _clamp(item['arcs'], 1, 3) elif kind == 'regularPolygon': out['n'] = _clamp(item.get('n', 5), 3, 12) out['r'] = _num(item.get('r', 2), 0.01, MAX_COORD) if item.get('center') is not None: out['center'] = _ref(item['center']) if item.get('start') is not None: out['start'] = _num(item['start'], -360, 360) names = item.get('names') if isinstance(names, list): clean = [n for n in names[:out['n']] if isinstance(n, str) and _NAME_OK.match(n)] if clean: out['names'] = clean elif kind == 'exteriorAngle': out.update({'vertex': _ref(item['vertex']), 'from': _ref(item['from']), 'to': _ref(item['to'])}) 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] if isinstance(item.get('fill'), bool): out['fill'] = item['fill'] elif kind == 'function': 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 == 'triangle': names = _names(item.get('names') or ['A', 'B', 'C'], 3) out['names'] = names sides, symbolic = {}, {} for key, value in list((item.get('sides') or {}).items())[:3]: if _side(key, names): try: sides[key] = _num(value, 0.001, MAX_COORD) except Invalid: if isinstance(value, str) and value.strip(): symbolic[key] = value if symbolic: item = {**item, 'labels': {**symbolic, **(item.get('labels') or {})}} angles = {} for key, value in list((item.get('angles') or {}).items())[:3]: if key in names: angles[key] = _num(value, 0.1, 179.9) if sum(angles.values()) >= 180: raise ValueError('angles') if sides: out['sides'] = sides if angles: out['angles'] = angles _shape_labels(item, out, names) if item.get('altitude') in names: out['altitude'] = item['altitude'] if item.get('at') is not None: out['at'] = _xy(item['at']) if isinstance(item.get('fill'), bool): out['fill'] = item['fill'] elif kind == 'quad': item = _quad_aliases(item) shape = item.get('shape') if shape is None: shape = 'square' if item.get('side') is not None and item.get('w') is None else 'rectangle' if shape not in _QUADS: raise ValueError('quad shape') out['shape'] = shape names = _names(item.get('names') or ['A', 'B', 'C', 'D'], 4) out['names'] = names symbolic = {} for key in ('w', 'h', 'side', 'top', 'd1', 'd2'): if item.get(key) is None: continue try: out[key] = _num(item[key], 0.001, MAX_COORD) except Invalid: # «"side":"a"»: اندازه‌ی نمادین؛ شکل با پیش‌فرض کشیده می‌شود و «a» برچسب می‌شود if isinstance(item[key], str) and item[key].strip(): symbolic[key] = item[key] if item.get('angle') is not None: out['angle'] = _num(item['angle'], 1, 179) if isinstance(item.get('right'), bool): out['right'] = item['right'] if item.get('at') is not None: out['at'] = _xy(item['at']) if symbolic: edge = {'w': names[0] + names[1], 'side': names[0] + names[1], 'top': names[3] + names[2], 'h': 'h' if shape in ('parallelogram', 'trapezoid', 'rhombus') else names[1] + names[2], 'd1': 'd1', 'd2': 'd2'} item = {**item, 'labels': {**{edge[k]: v for k, v in symbolic.items()}, **(item.get('labels') or {})}} _shape_labels(item, out, names, extra=('h', 'd1', 'd2')) if isinstance(item.get('fill'), bool): out['fill'] = item['fill'] 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}') 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 _SPACE_COLORS = {'blue', 'red', 'green', 'orange', 'purple', 'teal'} def _space(spec: dict, title: str) -> dict | None: """ فضای سه‌بعدی با تصویرِ مایل (ثابت، نه چرخان). مثلِ plane: آیتمِ خراب حذف می‌شود، شکلِ بی‌آیتم None است. مختصات در بازه‌ی ±۱۰۰ و فقط عدد؛ هیچ متنِ آزادی جز برچسب‌های کوتاه نمی‌گذرد. """ points = {'O': [0.0, 0.0, 0.0]} raw = spec.get('points') or {} if not isinstance(raw, dict): return None for name, value in list(raw.items())[:MAX_POINTS]: if not isinstance(name, str) or not _NAME_OK.match(name): continue try: if not (isinstance(value, (list, tuple)) and len(value) == 3): raise ValueError('triple') points[name] = [_num(v, -100, 100) for v in value] except ValueError: continue items = [] for it in (spec.get('items') or [])[:MAX_ITEMS + 8]: if not isinstance(it, dict): continue try: kind = it.get('type') out = {'type': kind} if it.get('color') in _SPACE_COLORS: out['color'] = it['color'] if it.get('label'): out['label'] = _label(it['label'], 24) if kind in ('point', 'drop'): if it.get('point') not in points: continue out['point'] = it['point'] elif kind in ('vector', 'segment'): if it.get('from') not in points or it.get('to') not in points: continue out['from'], out['to'] = it['from'], it['to'] if it.get('dashed'): out['dashed'] = True elif kind == 'polygon': names = it.get('points') if not (isinstance(names, list) and 3 <= len(names) <= 6 and all(n in points for n in names)): continue out['points'] = list(names) elif kind == 'box': to = it.get('to') if not (isinstance(to, (list, tuple)) and len(to) == 3): continue out['to'] = [_num(v, -100, 100) for v in to] else: continue items.append(out) except ValueError: continue if not items and len(points) < 2: return None return {'kind': 'space', 'title': title, 'axes': spec.get('axes') is not False, 'points': points, 'items': items[:MAX_ITEMS + 8]} def validate(spec) -> dict | None: """ نسخه‌ی تمیزِ spec، یا None. فقط کلیدها و نوع‌های شناخته‌شده رد می‌شوند؛ item یا نقطه‌ی خراب تنها حذف می‌شود، ولی شکلی که چیزی برای کشیدن ندارد None است. """ if not isinstance(spec, dict): return None kind = spec.get('kind', 'plane') try: title = _label(spec.get('title')) except ValueError: title = '' try: if kind == 'plane': return _plane(spec, title) if kind == 'numberline': return _numberline(spec, title) if kind == 'space': return _space(spec, title) if kind == 'fraction': shape = spec.get('shape') if spec.get('shape') in ('circle', 'rect') else 'circle' 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 == 'pattern': stages = [_clamp(v, 0, 12) for v in (spec.get('stages') or [])[:5]] if not stages: return None layout = spec.get('layout') if spec.get('layout') in _LAYOUTS else 'row' unit = spec.get('unit') if spec.get('unit') in ('square', 'dot', 'stick') else 'square' out = {'kind': 'pattern', 'title': title, 'layout': layout, 'unit': unit, 'stages': stages} for key in ('next', 'counts'): if isinstance(spec.get(key), bool): out[key] = spec[key] if spec.get('color') in COLORS: out['color'] = spec['color'] 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 def extract(text: str) -> tuple[str, dict | None]: """(متنِ بی‌بلوک، spec یا None). بلوکِ بسته‌نشده هم جدا می‌شود تا JSON در متن نماند.""" if OPEN not in (text or ''): return text, None match = _BLOCK.search(text) spec = parse(match.group(1)) if match else 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) # عددی که مدل به‌صورتِ حساب نوشت: «5/3»، «-2 * 3.14»، «(1+2)/4» — فقط عدد و + − × ÷ و پرانتز _ARITH_VALUE = re.compile(r'(?<=[:\[,])(\s*)([-+(]*\s*\d[\d.\s]*(?:[-+*/]\s*[(]*\s*\d[\d.\s]*[)]*\s*)+[)]*)(?=\s*[,\]}])') # رشته‌ای که پیش از } یا ] بسته نشده: «"color":"orange}]}» (سنجشِ ۲۰۲۶-۰۹-۲۹، ۲ از ۶۴) _UNCLOSED = re.compile(r'(:\s*"[^"\n{}\[\]]*?)(?=\s*[}\]])') def _arith(node): if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return float(node.value) if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)): v = _arith(node.operand) return -v if isinstance(node.op, ast.USub) else v if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)): a, b = _arith(node.left), _arith(node.right) if isinstance(node.op, ast.Div): return a / b return a + b if isinstance(node.op, ast.Add) else a - b if isinstance(node.op, ast.Sub) else a * b raise ValueError('not arithmetic') def _fractions(body: str) -> str: """«"to": 5/3» → 1.666667؛ فقط عددِ بی‌گیومه، نه برچسبِ «۱۲/۱۸».""" def value(m): try: v = _arith(ast.parse(m.group(2).strip(), mode='eval').body) except (ValueError, SyntaxError, ZeroDivisionError, RecursionError): return m.group(0) return f'{m.group(1)}{round(v, 6)}' if math.isfinite(v) else m.group(0) return _ARITH_VALUE.sub(value, body) def _merge_duplicates(pairs): """کلیدِ تکراری («points» دو بار) با هم ادغام می‌شود، نه اینکه دومی اولی را پاک کند.""" out = {} for key, value in pairs: old = out.get(key) if isinstance(old, dict) and isinstance(value, dict): out[key] = {**old, **value} elif isinstance(old, list) and isinstance(value, list): out[key] = old + value else: out[key] = value return out def _loads(body: str): body = _fractions(_repair(body)) try: return json.loads(body, object_pairs_hook=_merge_duplicates) except json.JSONDecodeError: return json.loads(_UNCLOSED.sub(r'\1"', body), object_pairs_hook=_merge_duplicates) 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 isinstance(obj.get('shapes'), list) and obj['shapes'] and isinstance(obj['shapes'][0], dict): return _normalize(obj['shapes'][0]) if kind == 'compare' and isinstance(obj.get('compare'), list): return {**obj, 'kind': 'fraction'} # «"kind":"trapezoid","bases":[10,6],"height":4» (چتِ واقعی، ۲۰۲۶-۰۹-۲۹): نامِ شکل به‌جای quad if kind in _QUADS: obj = {**obj, 'kind': 'quad', 'shape': kind} kind = 'quad' # «"kind":"quad"» یا «"kind":"triangle"» به‌جای item داخلِ plane (سنجشِ ۲۰۲۶-۰۹-۲۹: ۱۵ از ۴۰) if kind in _WRAPPED_ITEMS: item = {k: v for k, v in obj.items() if k not in ('kind', 'title', 'axes', 'grid', 'points', 'items')} item['type'] = kind extra = [i for i in (obj.get('items') or []) if isinstance(i, dict)] if isinstance(obj.get('items'), list) else [] if kind in ('triangle', 'quad'): # ضلع‌های تکراری که مدل کنارش کشید، روی ضلع‌های خودِ شکل می‌افتند و لازم نیستند extra = [i for i in extra if i.get('type') != 'segment' or i.get('dashed')] plane = {'kind': 'plane', 'title': obj.get('title', ''), 'points': obj.get('points') or {}, 'items': [item, *extra]} for key in ('axes', 'grid'): if key in obj: plane[key] = obj[key] return plane 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 # بزرگ‌ترین شکلِ معتبرِ سنجش‌ها چند کیلوبایت بود؛ بیش از این نشانه‌ی خروجیِ خراب یا دست‌کاری‌شده است. MAX_DIAGRAM_CHARS = 8000 def parse(body: str) -> dict | None: """ شکلِ معتبر یا None. هیچ خطایی بیرون نمی‌رود: شکل اختیاری است، و خطایی که از این‌جا بالا می‌رفت (OverflowError از عددِ ۴۰۰ رقمی، RecursionError از JSONِ خیلی تودرتو) در استریم کلِ جواب را «شکست» حساب می‌کرد و سوال را رایگان و بی‌ثبت می‌گذاشت، در حالی که متن به کاربر رسیده بود. """ body = (body or '').strip() if len(body) > MAX_DIAGRAM_CHARS: logger.warning('diagram: بلوکِ شکل %s نویسه بود (سقف %s)، دور ریخته شد', len(body), MAX_DIAGRAM_CHARS) return None if body.startswith('```'): body = body.strip('`').removeprefix('json').strip() try: return validate(_normalize(_loads(body))) except (json.JSONDecodeError, TypeError, ValueError): logger.info('diagram: JSONِ نامعتبر از مدل، دور ریخته شد') return None except Exception: logger.exception('diagram: خطای پیش‌بینی‌نشده در خواندنِ شکل، دور ریخته شد') return None class StreamSplitter: """ متنِ استریم را عبور می‌دهد و بلوکِ را کنار می‌گذارد. تهِ هر تکه که ممکن است آغازِ «» باشد نگه داشته می‌شود، چون برچسب می‌تواند بینِ دو تکه‌ی استریم نصف شود. """ def __init__(self): self._pending = '' self._block: list[str] | None = None self.diagram: dict | None = None def feed(self, delta: str) -> str: if self._block is not None: self._block.append(delta) joined = ''.join(self._block) if CLOSE not in joined: return '' body, rest = joined.split(CLOSE, 1) self.diagram = self.diagram or parse(body) self._block = None return self.feed(rest) if rest else '' text = self._pending + delta self._pending = '' at = text.find(OPEN) if at >= 0: self._block = [] return text[:at] + self.feed(text[at + len(OPEN):]) for keep in range(min(len(OPEN) - 1, len(text)), 0, -1): if OPEN.startswith(text[-keep:]): self._pending = text[-keep:] return text[:-keep] return text def flush(self) -> str: if self._block is not None: self.diagram = self.diagram or parse(''.join(self._block)) self._block = None return '' tail, self._pending = self._pending, '' return tail