1196 lines
62 KiB
Python
1196 lines
62 KiB
Python
"""
|
||
شکلِ پاسخِ دستیار: مدل یک توصیفِ JSONِ کوتاه در <diagram>…</diagram> مینویسد و
|
||
اپ خودش آن را به 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 = '<diagram>', '</diagram>'
|
||
_BLOCK = re.compile(r'<diagram>(.*?)(?:</diagram>|$)', re.S)
|
||
|
||
DIAGRAM_RULE = (
|
||
'شکل (اپ این را به تصویر تبدیل میکند):\n'
|
||
'- اگر سوال شکل دارد (هندسه، حجم، محورِ اعداد، نمودارِ تابع، نمودارِ آماری، کسر، '
|
||
'مجموعه، احتمال، ساعت)، در پایانِ پاسخ دقیقاً یک بلوکِ <diagram>…</diagram> بگذار که '
|
||
'داخلش فقط یک JSON است، نه SVG و نه HTML. قالبِ دقیقش کنارِ همان سوال میآید.\n'
|
||
'- برای حسابِ عددی یا سادهکردنِ عبارتِ جبری که شکلی ندارد، شکل نگذار.'
|
||
)
|
||
|
||
# کاتالوگِ نوعها، بخشبخش. کنارِ هر سوال فقط بخشهایی میرود که کلمههایش در سوال
|
||
# هست: کاتالوگِ کامل ۱٬۸۵۴ توکن بود (سنجیده با خودِ API، ۲۰۲۶-۰۹-۲۸) و هزینهی هر
|
||
# سوالِ شکلدار را تقریباً دو برابر میکرد.
|
||
_HEAD = (
|
||
'قالبِ بلوکِ <diagram>: فقط یک 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 = (
|
||
'این سوال شکل دارد: در پایانِ پاسخ بلوکِ <diagram>…</diagram> را طبقِ این قالب بگذار.'
|
||
)
|
||
|
||
|
||
FOLLOW_UP_HINT = (
|
||
'اگر این سوال ادامهی همان مسئلهی شکلدارِ قبلی است (عدد یا اندازه عوض شده، یا توضیحِ بیشتر '
|
||
'خواسته)، در پایانِ پاسخ شکلِ تازه را با عددهای تازه در بلوکِ <diagram>…</diagram> بکش؛ '
|
||
'اگر نیست، شکل نگذار.'
|
||
)
|
||
|
||
|
||
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'(?<![\u0600-\u06FF])(?:شکل|رسم|بکش|تصویر)')
|
||
ASKED_HINT = (
|
||
'دانشآموز شکل خواسته: اگر مسئله شکلی دارد، در پایانِ پاسخ بلوکِ <diagram>…</diagram> را '
|
||
'طبقِ این قالب بگذار؛ اگر ندارد، کوتاه بگو که این مسئله شکل ندارد.'
|
||
)
|
||
|
||
|
||
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:
|
||
"""
|
||
متنِ استریم را عبور میدهد و بلوکِ <diagram> را کنار میگذارد. تهِ هر تکه که
|
||
ممکن است آغازِ «<diagram>» باشد نگه داشته میشود، چون برچسب میتواند بینِ
|
||
دو تکهی استریم نصف شود.
|
||
"""
|
||
|
||
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
|