diagrams for every grade, sectioned prompt, repair of real model slips; fill lesson texts and thin islands
Diagrams: 14 kinds drawn by the app (plane with constructed points, graphs with computed roots/extrema/tangents, number lines with intervals, solids, charts, tables, Venn, mapping, trees, fractions, grids, groups, clock). The catalog is split by topic and only the matching sections go next to a question (200-1073 tokens instead of 1854). parse() repairs slips seen on 96 real book problems: Persian digits in JSON numbers, 2*pi, // comments, bare items, Venn with Persian set names. Measured: 45/48 valid on the second round, 0/8 on plain arithmetic; visual review of 83: 64 right, 3 weak, 16 wrong. Content: 82 new questions so no island has fewer than four; fill_lesson_texts writes Unit.blurb (58) and Lesson.example/steps (590) from verified book problems, never the lesson's own quiz problem. seed_content only fills empty fields, so admin edits survive a deploy. Tedi unlocks at 500 xp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,25 @@
|
||||
"""
|
||||
شکلِ پاسخِ دستیار: مدل یک توصیفِ JSONِ کوتاه در <diagram>…</diagram> مینویسد و
|
||||
اپ خودش آن را به SVG تبدیل میکند.
|
||||
اپ خودش آن را به SVG تبدیل میکند (MathApp_frontend، src/diagram).
|
||||
|
||||
چرا مدل خودش SVG/HTML نمینویسد (سنجیده ۲۰۲۶-۰۹-۲۸ روی gpt-4.1-mini):
|
||||
- **امنیت:** HTMLِ مدل (که متنِ کتاب و سوالِ کاربر را هم خوانده) اگر مستقیم در
|
||||
صفحه بنشیند، راهِ تزریقِ اسکریپت است. JSONِ اعتبارسنجیشده فقط داده است.
|
||||
- **درستیِ هندسه:** مدل در حسابِ مختصات (cos 30° و …) خطا میکند؛ اینجا فقط
|
||||
میگوید «نقطه روی دایره با زاویهی ۳۰°» و فرانت مختصات را حساب میکند.
|
||||
- **هزینه و سرعت:** یک JSONِ ۱۰۰–۲۰۰ توکنی بهجای SVGِ چندصدتوکنی.
|
||||
میگوید «نقطه روی دایره با زاویهی ۳۰°»، «وسطِ AB»، «ریشههای تابع» و فرانت
|
||||
همه را دقیق حساب میکند.
|
||||
- **هزینه و سرعت:** یک JSONِ ۱۰۰–۳۰۰ توکنی بهجای SVGِ چندصدتوکنی.
|
||||
- Structured Outputs (json_schema) را این مدل دارد ولی کلِ پاسخ را JSON
|
||||
میکند و استریمِ متن را خراب میکند؛ برای همین فقط بلوکِ آخر JSON است.
|
||||
|
||||
این ماژول سه کار میکند: قاعدهی پرامپت (`DIAGRAM_RULE`)، جدا کردنِ بلوک از متن
|
||||
(قبل از `mathtext`، که JSON را خراب میکرد)، و اعتبارسنجیِ سختگیر. شکلِ
|
||||
نامعتبر بیصدا دور ریخته میشود؛ متنِ پاسخ هیچوقت بهخاطرِ شکل از دست نمیرود.
|
||||
آزموده نشده: رفتارِ مدلهای دیگر (فقط gpt-4.1-mini سنجیده شد، ۱۸ سوال).
|
||||
دو تکهی پرامپت: `DIAGRAM_RULE` کوتاه است و همیشه در پرامپتِ سیستم میآید؛
|
||||
`DIAGRAM_SPEC` (کاتالوگِ کاملِ نوعها) فقط کنارِ سوالی میآید که کلمهی شکلدار دارد
|
||||
(`hint_for`)، تا سوالِ حسابیِ ساده هزینهی توکنِ اضافه نداشته باشد.
|
||||
|
||||
این ماژول قاعدهها را میدهد، بلوک را از متن جدا میکند (قبل از `mathtext`، که JSON را
|
||||
خراب میکرد) و سختگیرانه اعتبارسنجی میکند: فقط کلید و نوع و رنگِ شناختهشده، عددِ
|
||||
متناهی و محدود، متنِ کوتاه. تکهی نامعتبر بیصدا حذف میشود؛ متنِ پاسخ هیچوقت
|
||||
بهخاطرِ شکل از دست نمیرود. آزموده نشده: رفتارِ مدلهای دیگر جز gpt-4.1-mini.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
@@ -28,65 +33,142 @@ _BLOCK = re.compile(r'<diagram>(.*?)(?:</diagram>|$)', re.S)
|
||||
|
||||
DIAGRAM_RULE = (
|
||||
'شکل (اپ این را به تصویر تبدیل میکند):\n'
|
||||
'- وقتی سوال دربارهی یکی از اینهاست، در پایانِ پاسخ دقیقاً یک بلوکِ '
|
||||
'<diagram>…</diagram> بگذار: شکلِ هندسی (مثلث، چندضلعی، دایره، زاویه، خطِ موازی)، '
|
||||
'دایرهی مثلثاتی، حرکت یا مقایسه روی محورِ اعداد، نقطه یا نمودار در صفحهی مختصات، '
|
||||
'کسر یا درصد بهصورتِ تکههای یک شکل. هر سوالی که کلمهی مثلث، دایره، زاویه، محور، '
|
||||
'نمودار، کسر یا تکه دارد، شکل میخواهد. فقط برای حسابِ عددی یا سادهکردنِ عبارتِ '
|
||||
'جبری که شکلی ندارد، شکل نگذار.\n'
|
||||
'- داخلِ بلوک فقط یک JSON است، نه متنِ دیگر، نه SVG، نه HTML. مختصات را خودت حساب '
|
||||
'نکن؛ نقطهی روی دایره را با polar بده ([شعاع، زاویه به درجه]) تا اپ دقیق رسمش کند.\n'
|
||||
'- در متنِ پاسخ به شکل اشاره کن («در شکلِ پایین…») و برچسبهای شکل همان نامهای متن باشند.\n'
|
||||
'- سه نوع شکل:\n'
|
||||
' ۱) صفحهی مختصات یا هندسه:\n'
|
||||
' {"kind":"plane","title":"…","axes":true,"points":{"O":[0,0],"A":[1,0],"P":{"polar":[1,30]}},'
|
||||
'"items":[{"type":"circle","center":"O","r":1},{"type":"segment","from":"O","to":"P"},'
|
||||
'{"type":"angle","vertex":"O","from":"A","to":"P","label":"30°"},'
|
||||
'{"type":"rightAngle","vertex":"H","from":"A","to":"B"},{"type":"polygon","points":["A","B","C"]},'
|
||||
'{"type":"line","through":["A","B"]},{"type":"arrow","from":"A","to":"B"},'
|
||||
'{"type":"point","point":"V","label":"رأس (1, −1)"},'
|
||||
'{"type":"function","expr":"x^2-2*x","from":-1,"to":3,"label":"y = x² − 2x"},'
|
||||
'{"type":"text","at":[0.5,1.2],"text":"…"}]}\n'
|
||||
' نقطهها با نامشان برچسب میخورند (برچسبِ دیگر با item نوعِ point)؛ نقطهی کمکی که '
|
||||
'برچسب نمیخواهد با نامِ شروعشده با «_» بیاید. ضلع یا اندازه را با label روی segment '
|
||||
'بنویس: {"type":"segment","from":"A","to":"B","label":"۳"}. در function فقط x، عدد، '
|
||||
'+ − * / ^ ، پرانتز و sin cos tan sqrt abs log بنویس. "axes": false برای شکلِ هندسیِ بیمحور '
|
||||
'(مثلث، چندضلعی)؛ true فقط برای صفحهی مختصات و نمودار. line خطِ بیپایان است؛ ضلع، قطر و شعاع '
|
||||
'همیشه segmentاند.\n'
|
||||
' ۲) محورِ اعداد: {"kind":"numberline","title":"…","min":-5,"max":5,'
|
||||
'"points":[{"at":2,"label":"شروع"},{"at":-1,"label":"پایان"}],'
|
||||
'"jumps":[{"from":2,"to":-1,"label":"۳ واحد به چپ"}]}\n'
|
||||
' ۳) کسر: {"kind":"fraction","title":"…","shape":"circle","parts":4,"shaded":3}\n'
|
||||
'- مثال: «وترِ مثلثِ قائمالزاویه با ضلعهای ۳ و ۴» → polygon با سه رأس، rightAngle روی رأسِ قائمه '
|
||||
'و label روی هر ضلع (وتر با «?» یا جوابش).\n'
|
||||
'- title کوتاه و فارسی. حداکثر ۱۲ نقطه و ۱۲ item.'
|
||||
'- اگر سوال شکل دارد (هندسه، حجم، محورِ اعداد، نمودارِ تابع، نمودارِ آماری، کسر، '
|
||||
'مجموعه، احتمال، ساعت)، در پایانِ پاسخ دقیقاً یک بلوکِ <diagram>…</diagram> بگذار که '
|
||||
'داخلش فقط یک JSON است، نه SVG و نه HTML. قالبِ دقیقش کنارِ همان سوال میآید.\n'
|
||||
'- برای حسابِ عددی یا سادهکردنِ عبارتِ جبری که شکلی ندارد، شکل نگذار.'
|
||||
)
|
||||
|
||||
# کلمههایی که یعنی «این سوال شکل دارد». سنجشِ ۲۰۲۶-۰۹-۲۸: قاعدهی تهِ پرامپتِ
|
||||
# سیستم بهتنهایی برای مثلث ۱ از ۴ و پنجضلعی ۰ از ۴ شکل میگذاشت؛ همان درسِ
|
||||
# «/» — قاعده کنارِ خودِ سوال اثر دارد، نه تهِ پرامپت.
|
||||
_SHAPE_WORDS = re.compile(
|
||||
r'مثلث|دایره|زاویه|ضلع|چندضلعی|مربع|مستطیل|لوزی|ذوزنقه|متوازی|شعاع|قطر|وتر|محیط|مساحت|'
|
||||
r'محور|نمودار|مختصات|سهمی|خط\s*راست|شیب|نقطه|کسر|تکه|پیتزا|کیک|درصد|سینوس|کسینوس|تانژانت|'
|
||||
r'sin|cos|tan|°|درجه'
|
||||
# کاتالوگِ نوعها، بخشبخش. کنارِ هر سوال فقط بخشهایی میرود که کلمههایش در سوال
|
||||
# هست: کاتالوگِ کامل ۱٬۸۵۴ توکن بود (سنجیده با خودِ API، ۲۰۲۶-۰۹-۲۸) و هزینهی هر
|
||||
# سوالِ شکلدار را تقریباً دو برابر میکرد.
|
||||
_HEAD = (
|
||||
'قالبِ بلوکِ <diagram>: فقط یک JSON، کلیدها انگلیسی، برچسبها فارسی یا ریاضی. هر شکل "kind" و '
|
||||
'"title"ِ کوتاهِ فارسی دارد. مختصاتی را که قابلِ حساب است خودت حساب نکن؛ بگو «چه»، اپ «کجا» را '
|
||||
'دقیق حساب میکند. رنگ فقط: blue red green orange purple teal pink gray black yellow. '
|
||||
'در متنِ پاسخ به شکل اشاره کن («در شکلِ پایین…») با همان نامها. یک شکلِ روشن بهتر از شکلِ شلوغ.'
|
||||
)
|
||||
_PLANE = (
|
||||
'plane (هندسه و مختصات): {"kind":"plane","title":"…","axes":false,"grid":false,'
|
||||
'"points":{"A":[0,0],"B":[4,0],"P":{"polar":[1,30]},"Q":{"polar":[2,60],"from":"A"},"M":{"mid":["A","B"]},'
|
||||
'"H":{"foot":["C","A","B"]},"I":{"intersect":[["A","B"],["C","D"]]},"T":{"along":["A","B",0.25]}},"items":[…]}\n'
|
||||
'نقطه: polar=[شعاع، درجه]؛ mid وسط؛ foot پای عمود از اولی بر خطِ دوتای بعد؛ intersect برخوردِ دو خط؛ along '
|
||||
'نقطهی روی AB به نسبتِ t. نامِ شروعشده با «_» برچسب نمیگیرد. axes=false برای مثلث و چندضلعی؛ grid=true برای '
|
||||
'کاغذِ شطرنجی (دوران، بردار).\n'
|
||||
'items (همه color، dashed و label اختیاری دارند): {"type":"point","point":"A"} · '
|
||||
'{"type":"segment","from":"A","to":"B","label":"۵","ticks":1} (ticks علامتِ ضلعِ مساوی ۱ تا ۳) · '
|
||||
'{"type":"vector","from":"A","to":"B"} · {"type":"line","through":["A","B"]} (خطِ بیپایان؛ ضلع و قطر segmentاند) · '
|
||||
'{"type":"line","eq":[3,5,4]} یعنی خطِ 3x + 5y = 4 (ضریبها را عیناً از صورتِ مسئله بردار؛ خودت شیب حساب نکن) · '
|
||||
'{"type":"ray","from":"A","through":"B"} · {"type":"circle","center":"O","r":2} یا "through":"A" · '
|
||||
'{"type":"arc","center":"O","r":2,"start":0,"end":60} · {"type":"sector",…} · '
|
||||
'{"type":"angle","vertex":"B","from":"A","to":"C","label":"۴۰°","arcs":1} (arcs ۱ تا ۳ برای زاویههای مساوی؛ '
|
||||
'زاویهی خارجیِ رأسِ B: نقطهی "E":{"along":["A","B",1.5]} روی امتدادِ AB، بعد angle از E تا C) · '
|
||||
'{"type":"rightAngle","vertex":"H","from":"A","to":"B"} · {"type":"polygon","points":["A","B","C"]} · '
|
||||
'{"type":"image","of":["A","B","C"],"transform":{"rotate":{"center":"C","deg":-90}}} (نامِ تصویرها A′؛ '
|
||||
'rotate مثبت=پادساعتگرد و «در جهتِ عقربهها» منفی؛ یا {"reflect":"x"|"y"|"y=x"|["A","B"]}، {"translate":[2,1]}، '
|
||||
'{"scale":{"center":"O","k":2}}) · {"type":"text","at":[1,2],"text":"…"}'
|
||||
)
|
||||
_GRAPH = (
|
||||
'نمودارِ تابع (در plane با "axes":true؛ "trig":true یعنی محورِ x با مضربِ π؛ "xRange"/"yRange" اختیاری): '
|
||||
'{"type":"function","expr":"x^2-2*x-3","from":-2,"to":4,"label":"y = x² − 2x − 3",'
|
||||
'"marks":["roots","vertex","axis","yIntercept"]} (marks: roots ریشه، vertex رأس، extrema ماکزیمم/مینیمم، axis '
|
||||
'محورِ تقارن؛ اپ دقیق حساب میکند) · {"type":"piecewise","pieces":[{"expr":"x+1","from":-2,"to":1,'
|
||||
'"closedFrom":true,"closedTo":false},…]} (closed یعنی نقطهی توپُر؛ برای حد) · '
|
||||
'{"type":"tangent","expr":"x^2","at":1} · {"type":"area","expr":"sqrt(x)","from":0,"to":4} (سایهی زیرِ نمودار؛ '
|
||||
'expr2 برای بینِ دو نمودار) · {"type":"region","expr":"2*x-1","side":"above","strict":true} (نامعادله) · '
|
||||
'{"type":"vline","x":2} و {"type":"hline","y":1} (مجانب) · {"type":"point","point":[1,2],"open":true} · '
|
||||
'نقطهی روی نمودار: {"fx":["x^2",3]}. در expr فقط x، عدد، + - * / ^، پرانتز و sin cos tan sqrt abs log ln pi e؛ '
|
||||
'log₂x را log(x)/log(2) بنویس.'
|
||||
)
|
||||
_NUMBERLINE = (
|
||||
'numberline: {"kind":"numberline","title":"…","min":-5,"max":5,"step":1,'
|
||||
'"points":[{"at":2,"label":"شروع","open":false}],"jumps":[{"from":2,"to":-1,"label":"۳ واحد به چپ"}],'
|
||||
'"intervals":[{"from":-2,"to":3,"closedFrom":true,"closedTo":false,"label":"[-2, 3)"},{"from":1,"to":null}]} '
|
||||
'(null یعنی بینهایت؛ برای کسر روی محور "step":0.25 و "fractions":true؛ فاصله روی یک مسیر هم با همین)'
|
||||
)
|
||||
_PARTS = (
|
||||
'fraction: {"kind":"fraction","title":"…","shape":"circle"|"rect","parts":4,"shaded":3,"wholes":0} '
|
||||
'(wholes تعدادِ واحدِ کامل برای عددِ مخلوط: دو و سهچهارم = wholes 2، parts 4، shaded 3؛ مقایسهی چند کسر: '
|
||||
'"compare":[{"parts":5,"shaded":1,"label":"یکپنجم"},{"parts":10,"shaded":9}] که نوارهای هماندازه میکشد) · '
|
||||
'grid (درصد، شمردنِ خانه): {"kind":"grid","title":"…","rows":5,"cols":5,"shaded":5} · '
|
||||
'groups (مدلِ ضرب و تقسیم): {"kind":"groups","title":"…","groups":3,"each":4,"item":"circle"|"square"|"star"}. '
|
||||
'کسرِ جبری (با x یا a) و ساده کردنِ عبارت شکل ندارد.'
|
||||
)
|
||||
_CLOCK = 'clock: {"kind":"clock","title":"…","hour":3,"minute":25}'
|
||||
_CHARTS = (
|
||||
'bar | linechart | pie: {"kind":"bar","title":"…","labels":["سیب","پرتقال"],"values":[5,8],"unit":"نفر"} '
|
||||
'(درصدِ دایرهای را اپ حساب میکند)'
|
||||
)
|
||||
_TABLE = (
|
||||
'table (جدولِ مقدار یا تعیینِ علامت، چپ به راست): {"kind":"table","title":"…",'
|
||||
'"headers":["x","−∞","−1","","2","+∞"],"rows":[["y","+","0","−","0","+"]]}'
|
||||
)
|
||||
_SETS = (
|
||||
'venn: {"kind":"venn","title":"…","sets":["A","B"],"regions":{"A":["1","2"],"AB":["3"],"B":["5"],"out":["6"]},'
|
||||
'"shade":["AB"]} (ناحیه با نامِ مجموعههایی که در آناند؛ «A» یعنی فقط A؛ تا سه مجموعه) · '
|
||||
'mapping (نمودارِ پیکانی): {"kind":"mapping","title":"…","left":["1","2"],"right":["3","5"],"arrows":[[0,0],[1,1]],'
|
||||
'"leftLabel":"دامنه","rightLabel":"برد"}'
|
||||
)
|
||||
_TREE = (
|
||||
'tree (احتمال، شمارش): {"kind":"tree","title":"…","root":{"label":"شروع","children":[{"label":"رو","p":"1/2",'
|
||||
'"children":[…]},…]}} (حداکثر سه سطح)'
|
||||
)
|
||||
_SOLID = (
|
||||
'solid: {"kind":"solid","title":"…","shape":"cube"|"cuboid"|"prism"|"pyramid"|"cylinder"|"cone"|"sphere"|"hemisphere",'
|
||||
'"dims":{"a":5,"b":3,"c":4,"n":6,"r":2,"h":5},"labels":{"a":"5 cm","h":"h","r":"r"}} (a پهنا، b عمق، c ارتفاعِ '
|
||||
'مکعبمستطیل؛ n ضلعهای قاعدهی منشور/هرم؛ dims نسبتِ واقعی. منشورِ مستطیلی و جعبه = cuboid؛ prism فقط برای قاعدهی '
|
||||
'مثلث، پنجضلعی و ششضلعی)'
|
||||
)
|
||||
|
||||
_W = lambda words: re.compile('|'.join(words)) # noqa: E731
|
||||
_GEOMETRY_WORDS = (r'مثلث', r'دایره(?!ای)', r'زاویه', r'ضلع', r'چندضلعی', r'مربع', r'مستطیل', r'لوزی', r'ذوزنقه',
|
||||
r'متوازی', r'شعاع', r'قطر', r'وتر', r'محیط', r'مساحت', r'کمان', r'محاطی', r'مماس', r'موازی',
|
||||
r'عمود', r'نیمساز', r'بردار', r'دوران', r'انتقال', r'تقارن', r'قرینه', r'بازتاب', r'مختصات',
|
||||
r'نقطه', r'سینوس', r'کسینوس', r'تانژانت', r'مثلثاتی', r'sin', r'cos', r'tan', r'°', r'درجه')
|
||||
_GRAPH_WORDS = (r'نمودار(?![\u0650\u200c]?\s*(?:ستونی|دایرهای|میلهای|درختی|پیکانی|ون|خط\s*شکسته))', r'تابع', r'سهمی', r'خط\s*راست',
|
||||
r'شیب', r'مجانب', r'مشتق', r'اکسترمم', r'ماکزیمم', r'مینیمم', r'صعودی', r'نزولی', r'پیوسته',
|
||||
r'حد\b', r'\blim', r'نامعادله', r'مماس', r'log', r'f\(x\)', r'y\s*=')
|
||||
SECTIONS = [
|
||||
(_W(_GEOMETRY_WORDS + _GRAPH_WORDS), _PLANE),
|
||||
(_W(_GRAPH_WORDS), _GRAPH),
|
||||
(_W((r'محور', r'بازه', r'نامعادله', r'واحد به')), _NUMBERLINE),
|
||||
(_W((r'کسر', r'تکه', r'پیتزا', r'کیک', r'درصد', r'خانه', r'گروه', r'نصف', r'چهارم')), _PARTS),
|
||||
# «۶ ساعت مطالعه» مدت است، نه ساعتِ دیواری؛ فقط عقربه یا «ساعت … و … دقیقه»
|
||||
(_W((r'عقربه', r'ساعت\s*[0-9۰-۹]+\s*(?::|و)\s*[0-9۰-۹]+', r'ساعت\s*چند')), _CLOCK),
|
||||
(_W((r'ستونی', r'میلهای', r'دایرهای', r'خط\s*شکسته', r'میانگین', r'آمار', r'فراوانی')), _CHARTS),
|
||||
(_W((r'جدول', r'تعیین\s*علامت')), _TABLE),
|
||||
(_W((r'مجموعه', r'\bون\b', r'اشتراک', r'اجتماع', r'پیکانی', r'دامنه', r'برد\b', r'زوج\s*مرتب')), _SETS),
|
||||
(_W((r'احتمال', r'سکه', r'تاس', r'درختی')), _TREE),
|
||||
(_W((r'مکعب', r'منشور', r'هرم', r'استوانه', r'مخروط', r'کره', r'حجم', r'نیمکره')), _SOLID),
|
||||
]
|
||||
DIAGRAM_SPEC = '\n'.join([_HEAD] + [text for _, text in SECTIONS])
|
||||
|
||||
DIAGRAM_HINT = (
|
||||
'این سوال شکل دارد: در پایانِ پاسخ بلوکِ <diagram>…</diagram> را طبقِ قاعدهی «شکل» بگذار.'
|
||||
'این سوال شکل دارد: در پایانِ پاسخ بلوکِ <diagram>…</diagram> را طبقِ این قالب بگذار.'
|
||||
)
|
||||
|
||||
|
||||
def hint_for(question: str) -> str:
|
||||
"""یادآوریِ کنارِ سوال، فقط برای سوالهایی که کلمهی شکلدار دارند."""
|
||||
return DIAGRAM_HINT if _SHAPE_WORDS.search(question or '') else ''
|
||||
"""یادآوری و فقط آن بخشهایی از کاتالوگ که به همین سوال میخورند؛ سوالِ بیشکل → ''."""
|
||||
text = question or ''
|
||||
parts = [section for pattern, section in SECTIONS if pattern.search(text)]
|
||||
if not parts:
|
||||
return ''
|
||||
return '\n'.join([DIAGRAM_HINT, _HEAD, *parts])
|
||||
|
||||
|
||||
MAX_POINTS = 16
|
||||
MAX_ITEMS = 16
|
||||
MAX_LABEL = 40
|
||||
MAX_COORD = 1000
|
||||
_EXPR_OK = re.compile(r'^[0-9x+\-*/^().,\s]*(?:(?:sin|cos|tan|sqrt|abs|log|ln|pi)[0-9x+\-*/^().,\s]*)*$')
|
||||
_NAME_OK = re.compile(r'^[\w′\']{1,6}$')
|
||||
COLORS = {'blue', 'red', 'green', 'orange', 'purple', 'teal', 'pink', 'gray', 'black', 'yellow'}
|
||||
_FUNCS = r'(?:sin|cos|tan|sqrt|abs|log|ln|pi|e)'
|
||||
_EXPR_OK = re.compile(rf'^(?:[0-9x+\-*/^().,\s]|{_FUNCS})*$')
|
||||
_NAME_OK = re.compile(r"^[\w′']{1,6}$")
|
||||
_MARKS = {'roots', 'vertex', 'extrema', 'yIntercept', 'axis'}
|
||||
_SOLIDS = {'cube', 'cuboid', 'prism', 'pyramid', 'cylinder', 'cone', 'sphere', 'hemisphere'}
|
||||
|
||||
Invalid = (ValueError, KeyError, TypeError, IndexError)
|
||||
|
||||
|
||||
def _num(value, lo=-MAX_COORD, hi=MAX_COORD):
|
||||
@@ -94,17 +176,26 @@ def _num(value, lo=-MAX_COORD, hi=MAX_COORD):
|
||||
raise ValueError('not a number')
|
||||
if not math.isfinite(value) or not lo <= value <= hi:
|
||||
raise ValueError('out of range')
|
||||
return round(float(value), 4)
|
||||
return round(float(value), 6)
|
||||
|
||||
|
||||
def _label(value) -> str:
|
||||
def _int(value, lo, hi):
|
||||
return int(round(_num(value, lo, hi)))
|
||||
|
||||
|
||||
def _clamp(value, lo, hi):
|
||||
"""شمارِ علامتها و خانهها: بیش از حد محدود میشود، نه اینکه کلِ تکه حذف شود."""
|
||||
return int(min(hi, max(lo, round(_num(value)))))
|
||||
|
||||
|
||||
def _label(value, limit=MAX_LABEL) -> str:
|
||||
if value is None:
|
||||
return ''
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
value = str(value)
|
||||
if not isinstance(value, str):
|
||||
raise ValueError('label')
|
||||
return value.strip()[:MAX_LABEL]
|
||||
return value.strip()[:limit]
|
||||
|
||||
|
||||
def _xy(value):
|
||||
@@ -113,18 +204,6 @@ def _xy(value):
|
||||
return [_num(value[0]), _num(value[1])]
|
||||
|
||||
|
||||
def _point_def(value):
|
||||
if isinstance(value, dict) and 'polar' in value:
|
||||
polar = value['polar']
|
||||
if not (isinstance(polar, (list, tuple)) and len(polar) == 2):
|
||||
raise ValueError('polar')
|
||||
out = {'polar': [_num(polar[0], 0, MAX_COORD), _num(polar[1], -3600, 3600)]}
|
||||
if 'from' in value:
|
||||
out['from'] = _ref(value['from'])
|
||||
return out
|
||||
return _xy(value)
|
||||
|
||||
|
||||
def _ref(value):
|
||||
"""نامِ نقطه یا مختصاتِ مستقیم."""
|
||||
if isinstance(value, str):
|
||||
@@ -134,40 +213,312 @@ def _ref(value):
|
||||
return _xy(value)
|
||||
|
||||
|
||||
def _expr(value) -> str:
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > 80:
|
||||
raise ValueError('expr')
|
||||
compact = value.replace(' ', '')
|
||||
if not _EXPR_OK.match(compact):
|
||||
raise ValueError('expr chars')
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _pair(value, conv):
|
||||
if not (isinstance(value, (list, tuple)) and len(value) == 2):
|
||||
raise ValueError('pair')
|
||||
return [conv(value[0]), conv(value[1])]
|
||||
|
||||
|
||||
def _point_def(value):
|
||||
if isinstance(value, dict):
|
||||
if 'polar' in value:
|
||||
polar = value['polar']
|
||||
out = {'polar': [_num(polar[0], 0, MAX_COORD), _num(polar[1], -3600, 3600)]}
|
||||
if value.get('from') is not None:
|
||||
out['from'] = _ref(value['from'])
|
||||
return out
|
||||
if 'mid' in value:
|
||||
return {'mid': _pair(value['mid'], _ref)}
|
||||
if 'foot' in value:
|
||||
f = value['foot']
|
||||
if not (isinstance(f, (list, tuple)) and len(f) == 3):
|
||||
raise ValueError('foot')
|
||||
return {'foot': [_ref(f[0]), _ref(f[1]), _ref(f[2])]}
|
||||
if 'intersect' in value:
|
||||
return {'intersect': _pair(value['intersect'], lambda line: _pair(line, _ref))}
|
||||
if 'along' in value:
|
||||
a = value['along']
|
||||
if not (isinstance(a, (list, tuple)) and len(a) == 3):
|
||||
raise ValueError('along')
|
||||
return {'along': [_ref(a[0]), _ref(a[1]), _num(a[2], -10, 10)]}
|
||||
if 'fx' in value:
|
||||
e, x = value['fx']
|
||||
return {'fx': [_expr(e), _num(x)]}
|
||||
raise ValueError('point def')
|
||||
return _xy(value)
|
||||
|
||||
|
||||
def _style(item: dict, out: dict) -> dict:
|
||||
color = item.get('color')
|
||||
if color in COLORS:
|
||||
out['color'] = color
|
||||
if isinstance(item.get('dashed'), bool):
|
||||
out['dashed'] = item['dashed']
|
||||
if item.get('label') is not None:
|
||||
label = _label(item['label'])
|
||||
if label:
|
||||
out['label'] = label
|
||||
return out
|
||||
|
||||
|
||||
def _transform(value) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError('transform')
|
||||
if 'rotate' in value:
|
||||
r = value['rotate']
|
||||
return {'rotate': {'center': _ref(r['center']), 'deg': _num(r['deg'], -3600, 3600)}}
|
||||
if 'reflect' in value:
|
||||
r = value['reflect']
|
||||
return {'reflect': r if r in ('x', 'y', 'y=x') else _pair(r, _ref)}
|
||||
if 'translate' in value:
|
||||
return {'translate': _xy(value['translate'])}
|
||||
if 'scale' in value:
|
||||
sc = value['scale']
|
||||
return {'scale': {'center': _ref(sc['center']), 'k': _num(sc['k'], -20, 20)}}
|
||||
raise ValueError('transform kind')
|
||||
|
||||
|
||||
def _range(item, lo_key='from', hi_key='to', lo=-5.0, hi=5.0):
|
||||
a, b = _num(item.get(lo_key, lo)), _num(item.get(hi_key, hi))
|
||||
if a >= b:
|
||||
raise ValueError('range')
|
||||
return a, b
|
||||
|
||||
|
||||
def _plane_item(item: dict) -> dict:
|
||||
kind = item.get('type')
|
||||
out = {'type': kind}
|
||||
if kind == 'circle':
|
||||
out.update(center=_ref(item['center']), r=_num(item['r'], 0, MAX_COORD))
|
||||
elif kind in ('segment', 'arrow'):
|
||||
out.update(**{'from': _ref(item['from'])}, to=_ref(item['to']))
|
||||
out: dict = {'type': kind}
|
||||
if kind == 'point':
|
||||
out['point'] = _ref(item['point'])
|
||||
if isinstance(item.get('open'), bool):
|
||||
out['open'] = item['open']
|
||||
elif kind in ('segment', 'arrow', 'vector'):
|
||||
out.update({'from': _ref(item['from']), 'to': _ref(item['to'])})
|
||||
if kind == 'segment' and item.get('ticks') is not None:
|
||||
out['ticks'] = _clamp(item['ticks'], 1, 3)
|
||||
elif kind == 'line':
|
||||
a, b = item['through']
|
||||
out['through'] = [_ref(a), _ref(b)]
|
||||
if item.get('eq') is not None:
|
||||
eq = item['eq']
|
||||
if not (isinstance(eq, (list, tuple)) and len(eq) == 3):
|
||||
raise ValueError('eq')
|
||||
a, b, c = (_num(v) for v in eq)
|
||||
if a == 0 and b == 0:
|
||||
raise ValueError('degenerate line')
|
||||
out['eq'] = [a, b, c]
|
||||
else:
|
||||
out['through'] = _pair(item['through'], _ref)
|
||||
elif kind == 'ray':
|
||||
out.update({'from': _ref(item['from']), 'through': _ref(item['through'])})
|
||||
elif kind == 'vline':
|
||||
out['x'] = _num(item['x'])
|
||||
elif kind == 'hline':
|
||||
out['y'] = _num(item['y'])
|
||||
elif kind == 'circle':
|
||||
out['center'] = _ref(item['center'])
|
||||
if item.get('through') is not None:
|
||||
out['through'] = _ref(item['through'])
|
||||
else:
|
||||
out['r'] = _num(item['r'], 0, MAX_COORD)
|
||||
if out['r'] <= 0:
|
||||
raise ValueError('radius')
|
||||
if isinstance(item.get('fill'), bool):
|
||||
out['fill'] = item['fill']
|
||||
elif kind in ('arc', 'sector'):
|
||||
out.update(center=_ref(item['center']), r=_num(item['r'], 0, MAX_COORD),
|
||||
start=_num(item.get('start', 0), -3600, 3600), end=_num(item.get('end', 90), -3600, 3600))
|
||||
elif kind in ('angle', 'rightAngle'):
|
||||
out.update(vertex=_ref(item['vertex']), **{'from': _ref(item['from'])}, to=_ref(item['to']))
|
||||
out.update({'vertex': _ref(item['vertex']), 'from': _ref(item['from']), 'to': _ref(item['to'])})
|
||||
if kind == 'angle' and item.get('arcs') is not None:
|
||||
out['arcs'] = _clamp(item['arcs'], 1, 3)
|
||||
elif kind == 'polygon':
|
||||
points = item['points']
|
||||
if not isinstance(points, list) or not 3 <= len(points) <= 12:
|
||||
raise ValueError('polygon')
|
||||
out['points'] = [_ref(p) for p in points]
|
||||
elif kind == 'point':
|
||||
out['point'] = _ref(item['point'])
|
||||
if isinstance(item.get('fill'), bool):
|
||||
out['fill'] = item['fill']
|
||||
elif kind == 'function':
|
||||
expr = item['expr']
|
||||
if not isinstance(expr, str) or len(expr) > 80 or not _EXPR_OK.match(expr.replace(' ', '')):
|
||||
raise ValueError('expr')
|
||||
out.update(expr=expr, **{'from': _num(item.get('from', -5))}, to=_num(item.get('to', 5)))
|
||||
if out['from'] >= out['to']:
|
||||
raise ValueError('function range')
|
||||
out['expr'] = _expr(item['expr'])
|
||||
out['from'], out['to'] = _range(item)
|
||||
marks = [m for m in (item.get('marks') or []) if m in _MARKS]
|
||||
if marks:
|
||||
out['marks'] = list(dict.fromkeys(marks))
|
||||
elif kind == 'piecewise':
|
||||
pieces = []
|
||||
for piece in (item.get('pieces') or [])[:6]:
|
||||
try:
|
||||
a, b = _range(piece)
|
||||
clean = {'expr': _expr(piece['expr']), 'from': a, 'to': b}
|
||||
for key in ('closedFrom', 'closedTo'):
|
||||
if isinstance(piece.get(key), bool):
|
||||
clean[key] = piece[key]
|
||||
pieces.append(clean)
|
||||
except Invalid:
|
||||
continue
|
||||
if not pieces:
|
||||
raise ValueError('pieces')
|
||||
out['pieces'] = pieces
|
||||
elif kind == 'tangent':
|
||||
out.update(expr=_expr(item['expr']), at=_num(item['at']))
|
||||
elif kind == 'area':
|
||||
out['expr'] = _expr(item['expr'])
|
||||
out['from'], out['to'] = _range(item)
|
||||
if item.get('expr2') is not None:
|
||||
out['expr2'] = _expr(item['expr2'])
|
||||
elif kind == 'region':
|
||||
out['expr'] = _expr(item['expr'])
|
||||
out['side'] = item['side'] if item.get('side') in ('above', 'below') else 'above'
|
||||
if item.get('from') is not None and item.get('to') is not None:
|
||||
out['from'], out['to'] = _range(item)
|
||||
if isinstance(item.get('strict'), bool):
|
||||
out['strict'] = item['strict']
|
||||
elif kind == 'image':
|
||||
of = item['of']
|
||||
if not isinstance(of, list) or not 1 <= len(of) <= 12:
|
||||
raise ValueError('image of')
|
||||
out['of'] = [_ref(p) for p in of]
|
||||
out['transform'] = _transform(item['transform'])
|
||||
names = item.get('names')
|
||||
if isinstance(names, list):
|
||||
out['names'] = [_label(n, 6) for n in names[:12]]
|
||||
elif kind == 'text':
|
||||
out.update(at=_xy(item['at']), text=_label(item.get('text')))
|
||||
if not out['text']:
|
||||
raise ValueError('empty text')
|
||||
if item.get('color') in COLORS:
|
||||
out['color'] = item['color']
|
||||
return out
|
||||
else:
|
||||
raise ValueError(f'unknown item {kind!r}')
|
||||
if kind != 'text' and item.get('label') is not None:
|
||||
out['label'] = _label(item['label'])
|
||||
return _style(item, out)
|
||||
|
||||
|
||||
def _plane(spec: dict, title: str) -> dict | None:
|
||||
raw_points = spec.get('points') or {}
|
||||
if not isinstance(raw_points, dict):
|
||||
return None
|
||||
points = {}
|
||||
for name, value in list(raw_points.items())[:MAX_POINTS]:
|
||||
if isinstance(name, str) and _NAME_OK.match(name):
|
||||
try:
|
||||
points[name] = _point_def(value)
|
||||
except Invalid:
|
||||
continue
|
||||
items = []
|
||||
raw_items = spec.get('items') or []
|
||||
if not isinstance(raw_items, list):
|
||||
return None
|
||||
for item in raw_items[:MAX_ITEMS]:
|
||||
if isinstance(item, dict):
|
||||
try:
|
||||
items.append(_plane_item(item))
|
||||
except Invalid:
|
||||
continue
|
||||
if not items and not points:
|
||||
return None
|
||||
# بیمحور مگر مدل صریحاً بخواهد یا نمودارِ تابع باشد: مثلثی که محور
|
||||
# گرفته بود عددهای محور را روی برچسبِ ضلعها میانداخت (۲۰۲۶-۰۹-۲۸).
|
||||
graphs = {'function', 'piecewise', 'tangent', 'area', 'region', 'vline', 'hline'}
|
||||
axes = spec.get('axes')
|
||||
if not isinstance(axes, bool):
|
||||
axes = any(i['type'] in graphs for i in items)
|
||||
out = {'kind': 'plane', 'title': title, 'axes': axes, 'points': points, 'items': items}
|
||||
for key in ('grid', 'trig'):
|
||||
if isinstance(spec.get(key), bool):
|
||||
out[key] = spec[key]
|
||||
for key in ('xRange', 'yRange'):
|
||||
try:
|
||||
a, b = _pair(spec[key], _num)
|
||||
if a < b:
|
||||
out[key] = [a, b]
|
||||
except Invalid:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _numberline(spec: dict, title: str) -> dict | None:
|
||||
lo, hi = _num(spec.get('min', -5)), _num(spec.get('max', 5))
|
||||
if not lo < hi or hi - lo > 10000:
|
||||
return None
|
||||
out = {'kind': 'numberline', 'title': title, 'min': lo, 'max': hi, 'points': [], 'jumps': []}
|
||||
if spec.get('step') is not None:
|
||||
try:
|
||||
step = _num(spec['step'], 0.001, 100)
|
||||
if (hi - lo) / step <= 40:
|
||||
out['step'] = step
|
||||
except Invalid:
|
||||
pass
|
||||
if isinstance(spec.get('fractions'), bool):
|
||||
out['fractions'] = spec['fractions']
|
||||
for p in (spec.get('points') or [])[:MAX_POINTS]:
|
||||
try:
|
||||
clean = {'at': _num(p['at']), 'label': _label(p.get('label'))}
|
||||
if isinstance(p.get('open'), bool):
|
||||
clean['open'] = p['open']
|
||||
if p.get('color') in COLORS:
|
||||
clean['color'] = p['color']
|
||||
out['points'].append(clean)
|
||||
except Invalid:
|
||||
continue
|
||||
for j in (spec.get('jumps') or [])[:MAX_ITEMS]:
|
||||
try:
|
||||
out['jumps'].append({'from': _num(j['from']), 'to': _num(j['to']), 'label': _label(j.get('label'))})
|
||||
except Invalid:
|
||||
continue
|
||||
intervals = []
|
||||
for iv in (spec.get('intervals') or [])[:4]:
|
||||
try:
|
||||
a = None if iv.get('from') is None else _num(iv['from'])
|
||||
b = None if iv.get('to') is None else _num(iv['to'])
|
||||
if a is not None and b is not None and a >= b:
|
||||
continue
|
||||
clean = {'from': a, 'to': b}
|
||||
for key in ('closedFrom', 'closedTo'):
|
||||
if isinstance(iv.get(key), bool):
|
||||
clean[key] = iv[key]
|
||||
if iv.get('color') in COLORS:
|
||||
clean['color'] = iv['color']
|
||||
if iv.get('label'):
|
||||
clean['label'] = _label(iv['label'])
|
||||
intervals.append(clean)
|
||||
except Invalid:
|
||||
continue
|
||||
if intervals:
|
||||
out['intervals'] = intervals
|
||||
return out
|
||||
|
||||
|
||||
def _strings(values, limit, size=MAX_LABEL) -> list[str]:
|
||||
if not isinstance(values, list):
|
||||
raise ValueError('list')
|
||||
return [_label(v, size) for v in values[:limit]]
|
||||
|
||||
|
||||
def _tree(node, depth=0) -> dict:
|
||||
if not isinstance(node, dict):
|
||||
raise ValueError('tree node')
|
||||
out = {'label': _label(node.get('label'), 12)}
|
||||
if node.get('p') is not None:
|
||||
out['p'] = _label(node['p'], 10)
|
||||
children = node.get('children') or []
|
||||
if depth < 3 and isinstance(children, list) and children:
|
||||
kids = []
|
||||
for child in children[:6]:
|
||||
try:
|
||||
kids.append(_tree(child, depth + 1))
|
||||
except Invalid:
|
||||
continue
|
||||
if kids:
|
||||
out['children'] = kids
|
||||
return out
|
||||
|
||||
|
||||
@@ -185,57 +536,128 @@ def validate(spec) -> dict | None:
|
||||
title = ''
|
||||
try:
|
||||
if kind == 'plane':
|
||||
points = {}
|
||||
raw_points = spec.get('points') or {}
|
||||
if not isinstance(raw_points, dict):
|
||||
return None
|
||||
for name, value in list(raw_points.items())[:MAX_POINTS]:
|
||||
if isinstance(name, str) and _NAME_OK.match(name):
|
||||
try:
|
||||
points[name] = _point_def(value)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
continue
|
||||
items = []
|
||||
for item in (spec.get('items') or [])[:MAX_ITEMS]:
|
||||
if isinstance(item, dict):
|
||||
try:
|
||||
clean = _plane_item(item)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
continue
|
||||
items.append(clean)
|
||||
if not items and not points:
|
||||
return None
|
||||
# بیمحور مگر مدل صریحاً بخواهد یا نمودارِ تابع باشد: مثلثی که محور
|
||||
# گرفته بود عددهای محور را روی برچسبِ ضلعها میانداخت (۲۰۲۶-۰۹-۲۸).
|
||||
axes = spec.get('axes')
|
||||
if not isinstance(axes, bool):
|
||||
axes = any(i['type'] == 'function' for i in items)
|
||||
return {'kind': 'plane', 'title': title, 'axes': axes,
|
||||
'points': points, 'items': items}
|
||||
return _plane(spec, title)
|
||||
if kind == 'numberline':
|
||||
lo, hi = _num(spec.get('min', -5)), _num(spec.get('max', 5))
|
||||
if not lo < hi or hi - lo > 200:
|
||||
return None
|
||||
marks = []
|
||||
for p in (spec.get('points') or [])[:MAX_POINTS]:
|
||||
try:
|
||||
marks.append({'at': _num(p['at']), 'label': _label(p.get('label'))})
|
||||
except (ValueError, KeyError, TypeError):
|
||||
continue
|
||||
jumps = []
|
||||
for j in (spec.get('jumps') or [])[:MAX_ITEMS]:
|
||||
try:
|
||||
jumps.append({'from': _num(j['from']), 'to': _num(j['to']), 'label': _label(j.get('label'))})
|
||||
except (ValueError, KeyError, TypeError):
|
||||
continue
|
||||
return {'kind': 'numberline', 'title': title, 'min': lo, 'max': hi,
|
||||
'points': marks, 'jumps': jumps}
|
||||
return _numberline(spec, title)
|
||||
if kind == 'fraction':
|
||||
parts = int(_num(spec.get('parts'), 1, 24))
|
||||
shaded = int(_num(spec.get('shaded', 0), 0, parts))
|
||||
shape = spec.get('shape') if spec.get('shape') in ('circle', 'rect') else 'circle'
|
||||
return {'kind': 'fraction', 'title': title, 'shape': shape, 'parts': parts, 'shaded': shaded}
|
||||
except (ValueError, KeyError, TypeError):
|
||||
compare = []
|
||||
for f in (spec.get('compare') or [])[:4]:
|
||||
try:
|
||||
p = _int(f['parts'], 1, 24)
|
||||
clean = {'parts': p, 'shaded': _clamp(f.get('shaded', 0), 0, p)}
|
||||
if f.get('label'):
|
||||
clean['label'] = _label(f['label'], 16)
|
||||
compare.append(clean)
|
||||
except Invalid:
|
||||
continue
|
||||
if compare:
|
||||
return {'kind': 'fraction', 'title': title, 'shape': 'rect', 'parts': compare[0]['parts'],
|
||||
'shaded': compare[0]['shaded'], 'compare': compare}
|
||||
parts = _int(spec.get('parts'), 1, 24)
|
||||
shaded = _clamp(spec.get('shaded', 0), 0, parts)
|
||||
out = {'kind': 'fraction', 'title': title, 'shape': shape, 'parts': parts, 'shaded': shaded}
|
||||
if spec.get('wholes'):
|
||||
out['wholes'] = _clamp(spec['wholes'], 0, 5)
|
||||
return out
|
||||
if kind == 'grid':
|
||||
rows, cols = _int(spec.get('rows'), 1, 20), _int(spec.get('cols'), 1, 20)
|
||||
shaded = spec.get('shaded', 0)
|
||||
if isinstance(shaded, list):
|
||||
cells = []
|
||||
for cell in shaded[:400]:
|
||||
try:
|
||||
r, c = _pair(cell, lambda v: _int(v, 0, 19))
|
||||
if r < rows and c < cols:
|
||||
cells.append([r, c])
|
||||
except Invalid:
|
||||
continue
|
||||
shaded = cells
|
||||
else:
|
||||
shaded = _clamp(shaded, 0, rows * cols)
|
||||
out = {'kind': 'grid', 'title': title, 'rows': rows, 'cols': cols, 'shaded': shaded}
|
||||
if spec.get('color') in COLORS:
|
||||
out['color'] = spec['color']
|
||||
return out
|
||||
if kind == 'groups':
|
||||
out = {'kind': 'groups', 'title': title, 'groups': _int(spec.get('groups'), 1, 10), 'each': _int(spec.get('each'), 1, 12)}
|
||||
if spec.get('item') in ('circle', 'square', 'star'):
|
||||
out['item'] = spec['item']
|
||||
if spec.get('label'):
|
||||
out['label'] = _label(spec['label'])
|
||||
return out
|
||||
if kind == 'clock':
|
||||
return {'kind': 'clock', 'title': title, 'hour': _int(spec.get('hour'), 0, 24), 'minute': _int(spec.get('minute', 0), 0, 59)}
|
||||
if kind in ('bar', 'linechart', 'pie'):
|
||||
labels = _strings(spec.get('labels'), 12, 16)
|
||||
values = [_num(v, -1e6, 1e6) for v in (spec.get('values') or [])[:12]]
|
||||
n = min(len(labels), len(values))
|
||||
if not n or (kind == 'pie' and sum(v for v in values[:n] if v > 0) <= 0):
|
||||
return None
|
||||
out = {'kind': kind, 'title': title, 'labels': labels[:n], 'values': values[:n]}
|
||||
if spec.get('unit'):
|
||||
out['unit'] = _label(spec['unit'], 12)
|
||||
return out
|
||||
if kind == 'table':
|
||||
headers = _strings(spec.get('headers') or [], 10, 16)
|
||||
rows = [_strings(r, 10, 24) for r in (spec.get('rows') or [])[:12] if isinstance(r, list)]
|
||||
if not rows and not headers:
|
||||
return None
|
||||
return {'kind': 'table', 'title': title, 'headers': headers, 'rows': rows}
|
||||
if kind == 'venn':
|
||||
sets = [s for s in _strings(spec.get('sets'), 3, 1) if re.fullmatch(r'[A-Z]', s)]
|
||||
if not sets or len(set(sets)) != len(sets):
|
||||
return None
|
||||
regions = {}
|
||||
raw = spec.get('regions') or {}
|
||||
if isinstance(raw, dict):
|
||||
for code, members in list(raw.items())[:8]:
|
||||
if isinstance(code, str) and (code == 'out' or (code and set(code) <= set(sets))) and isinstance(members, list):
|
||||
regions[code] = _strings(members, 12, 8)
|
||||
shade = [c for c in (spec.get('shade') or []) if isinstance(c, str) and (c == 'out' or (c and set(c) <= set(sets)))][:8]
|
||||
out = {'kind': 'venn', 'title': title, 'sets': sets, 'regions': regions, 'shade': shade}
|
||||
names = spec.get('names')
|
||||
if isinstance(names, list) and len(names) == len(sets):
|
||||
out['names'] = [_label(n, 12) for n in names]
|
||||
return out
|
||||
if kind == 'mapping':
|
||||
left, right = _strings(spec.get('left'), 10, 10), _strings(spec.get('right'), 10, 10)
|
||||
arrows = []
|
||||
for a in (spec.get('arrows') or [])[:20]:
|
||||
try:
|
||||
i, j = _pair(a, lambda v: _int(v, 0, 9))
|
||||
if i < len(left) and j < len(right):
|
||||
arrows.append([i, j])
|
||||
except Invalid:
|
||||
continue
|
||||
if not left or not right:
|
||||
return None
|
||||
out = {'kind': 'mapping', 'title': title, 'left': left, 'right': right, 'arrows': arrows}
|
||||
for key in ('leftLabel', 'rightLabel'):
|
||||
if spec.get(key):
|
||||
out[key] = _label(spec[key], 16)
|
||||
return out
|
||||
if kind == 'tree':
|
||||
return {'kind': 'tree', 'title': title, 'root': _tree(spec.get('root'))}
|
||||
if kind == 'solid':
|
||||
shape = spec.get('shape')
|
||||
if shape not in _SOLIDS:
|
||||
return None
|
||||
dims = {}
|
||||
for key, (lo, hi) in {'a': (0.01, 1e4), 'b': (0.01, 1e4), 'c': (0.01, 1e4), 'r': (0.01, 1e4), 'h': (0.01, 1e4), 'n': (3, 8)}.items():
|
||||
try:
|
||||
if (spec.get('dims') or {}).get(key) is not None:
|
||||
dims[key] = _num(spec['dims'][key], lo, hi)
|
||||
except Invalid:
|
||||
continue
|
||||
labels = {}
|
||||
raw_labels = spec.get('labels') or {}
|
||||
if isinstance(raw_labels, dict):
|
||||
for key in ('a', 'b', 'c', 'r', 'h'):
|
||||
if raw_labels.get(key):
|
||||
labels[key] = _label(raw_labels[key], 12)
|
||||
return {'kind': 'solid', 'title': title, 'shape': shape, 'dims': dims, 'labels': labels}
|
||||
except Invalid:
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -249,13 +671,80 @@ def extract(text: str) -> tuple[str, dict | None]:
|
||||
return _BLOCK.sub('', text).rstrip(), spec
|
||||
|
||||
|
||||
_DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
|
||||
# مقدارِ عددیِ نوشتهشده بهشکلِ عبارت، که JSON نیست: -2*pi، pi/2، 3pi/4
|
||||
_PI_VALUE = re.compile(r'(?<=[:\[,])(\s*)(-?)\s*(\d*\.?\d*)\s*\*?\s*pi(?:\s*/\s*(\d+(?:\.\d+)?))?(?=\s*[,\]}])')
|
||||
|
||||
|
||||
def _repair(body: str) -> str:
|
||||
"""
|
||||
خطاهای قالبیِ واقعیِ gpt-4.1-mini (سنجشِ ۲۰۲۶-۰۹-۲۸، ۴۸ مسئلهی کتاب) که معنا را
|
||||
عوض نمیکنند: رقمِ فارسی در عدد («"rows":۱۰») و π بهصورتِ عبارت («"from":-2*pi»).
|
||||
رقمهای داخلِ برچسب هم لاتین میشوند؛ فرانت دوباره فارسیشان میکند.
|
||||
"""
|
||||
body = body.translate(_DIGITS)
|
||||
# توضیحِ «// …» بعد از یک مقدار (مدل برای خودش یادداشت گذاشته بود)
|
||||
body = re.sub(r'(?<=[,\[{\]}"\d])[ \t]*//[^\n]*', '', body)
|
||||
|
||||
def pi(m):
|
||||
sign, coef, den = m.group(2), m.group(3), m.group(4)
|
||||
value = (float(coef) if coef else 1.0) * math.pi / (float(den) if den else 1.0)
|
||||
return f'{m.group(1)}{"-" if sign else ""}{round(value, 6)}'
|
||||
|
||||
return _PI_VALUE.sub(pi, body)
|
||||
|
||||
|
||||
def _normalize(obj):
|
||||
"""
|
||||
شکلهایی که مدل با کمی فاصله از قالب نوشت ولی منظورش روشن است:
|
||||
- یک item تنها (با "type") بدونِ plane دورش؛
|
||||
- "kind":"function" با فیلدهای همان item؛
|
||||
- نمودارِ ون با نامِ فارسیِ مجموعهها («والیبال») بهجای A و B.
|
||||
"""
|
||||
if not isinstance(obj, dict):
|
||||
return obj
|
||||
kind = obj.get('kind')
|
||||
if kind is None and 'type' in obj:
|
||||
return {'kind': 'plane', 'title': obj.get('title', ''), 'points': {}, 'items': [obj]}
|
||||
if kind in ('function', 'piecewise') and 'expr' in obj or kind == 'function' and 'pieces' in obj:
|
||||
item = {k: v for k, v in obj.items() if k not in ('kind', 'title', 'axes', 'grid', 'trig', 'xRange', 'yRange', 'points')}
|
||||
item['type'] = 'piecewise' if 'pieces' in obj else 'function'
|
||||
plane = {'kind': 'plane', 'title': obj.get('title', ''), 'axes': True, 'points': obj.get('points') or {}, 'items': [item]}
|
||||
for key in ('grid', 'trig', 'xRange', 'yRange'):
|
||||
if key in obj:
|
||||
plane[key] = obj[key]
|
||||
return plane
|
||||
if kind == 'venn' and isinstance(obj.get('sets'), list):
|
||||
names = [str(n) for n in obj['sets'][:3]]
|
||||
if names and not all(re.fullmatch(r'[A-Z]', n) for n in names):
|
||||
letters = 'ABC'[:len(names)]
|
||||
index = {n: letters[i] for i, n in enumerate(names)}
|
||||
|
||||
def code(key: str) -> str | None:
|
||||
if key == 'out':
|
||||
return 'out'
|
||||
parts = [p for p in re.split(r'\s*[_∩&,،+]\s*|\s+و\s+', key) if p]
|
||||
if all(p in index for p in parts):
|
||||
return ''.join(sorted(index[p] for p in parts))
|
||||
return key if set(key) <= set(letters) else None
|
||||
|
||||
regions = {}
|
||||
for key, members in (obj.get('regions') or {}).items():
|
||||
c = code(str(key))
|
||||
if c:
|
||||
regions[c] = members
|
||||
obj = {**obj, 'sets': list(letters), 'names': names, 'regions': regions,
|
||||
'shade': [c for c in (code(str(k)) for k in obj.get('shade') or []) if c]}
|
||||
return obj
|
||||
|
||||
|
||||
def parse(body: str) -> dict | None:
|
||||
body = (body or '').strip()
|
||||
if body.startswith('```'):
|
||||
body = body.strip('`').removeprefix('json').strip()
|
||||
try:
|
||||
return validate(json.loads(body))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return validate(_normalize(json.loads(_repair(body))))
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
logger.info('diagram: JSONِ نامعتبر از مدل، دور ریخته شد')
|
||||
return None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user