Files
MathApp/rag/services/diagram.py
Zareian a973c41db2 Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks
- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and
  curriculum import need add/delete; content browser needs view_question; guest device ids are
  shown to superusers only.
- Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps
  Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted
  (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles.
- Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409;
  orders snapshot credits/hearts/days at creation (payment.0011).
- Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker
  refuses the answer (not stored, not cached); a disconnect saves the partial answer; only
  KNOWN_COLLECTIONS are searched.
- Infra: secret-key file is created atomically (workers no longer split keys); no default
  INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc
  pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400;
  ticket text capped at 5000.
- docs/security-before-production.md: remaining open items 24-31 and the guard tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:52:25 +03:30

1123 lines
58 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
شکلِ پاسخِ دستیار: مدل یک توصیفِ 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 فقط برای قاعده‌ی '
'مثلث، پنج‌ضلعی و شش‌ضلعی)'
)
_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),
]
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
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 == '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