diagram kit: triangles and quadrilaterals from problem data, shape patterns, translate points, arithmetic/quote/duplicate-key repairs, clock guard (tutor-v10-shape-anchor)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
10
CLAUDE.md
10
CLAUDE.md
@@ -403,7 +403,15 @@ anyone who can read this repo can forge a token for any user.**
|
|||||||
`?gallery` فقط در حالتِ توسعه. کاتالوگ بخشبخش است و کنارِ هر سوال فقط بخشِ مربوط میرود
|
`?gallery` فقط در حالتِ توسعه. کاتالوگ بخشبخش است و کنارِ هر سوال فقط بخشِ مربوط میرود
|
||||||
(`hint_for`)؛ سوالِ ادامهای بخشِ سوالِ قبلی را میگیرد (`follow_up_hint_for`)، و درخواستِ صریحِ
|
(`hint_for`)؛ سوالِ ادامهای بخشِ سوالِ قبلی را میگیرد (`follow_up_hint_for`)، و درخواستِ صریحِ
|
||||||
«شکلش رو بکش» بیکلمهی موضوعی کلِ کاتالوگ را (`asked_hint_for`، پرامپت `tutor-v8-asked-figure`؛
|
«شکلش رو بکش» بیکلمهی موضوعی کلِ کاتالوگ را (`asked_hint_for`، پرامپت `tutor-v8-asked-figure`؛
|
||||||
یک بار با GapGPT آزموده شد: مستطیلِ باغچه). فرانت هیچ تشخیصی نمیدهد. پرامپت `tutor-v6-diagrams`. سنجش روی ۹۶ مسئلهی واقعیِ کتاب: ۴۵ از ۴۸ شکلِ
|
یک بار با GapGPT آزموده شد: مستطیلِ باغچه). فرانت هیچ تشخیصی نمیدهد.
|
||||||
|
- **۲۰۲۶-۰۹-۲۹، پرامپت `tutor-v10-shape-anchor`:** ابزارهای `triangle` (مثلث از ضلع و زاویه، با قانونِ
|
||||||
|
سینوس/کسینوس در `src/diagram/shapes.ts`)، `quad` (مستطیل، مربع، متوازیالاضلاع، لوزی، ذوزنقه)، نوعِ
|
||||||
|
`pattern` (الگوی شکلی و چوبکبریت)، نقطهی `translate`؛ شکلِ دوم روی ضلعِ مشترک یا کنارِ شکلِ قبلی
|
||||||
|
مینشیند. تعمیرِ عددِ حسابی («-2 * 3.14»)، گیومهی جاافتاده، کلیدِ تکراری، `kind` با نامِ شکل؛
|
||||||
|
`fits()` ساعتِ دیواری را برای سوالِ بیساعت دور میریزد. سنجشِ ۶۴ مسئلهی تصادفیِ کتاب (همان مسئلهها
|
||||||
|
قبل و بعد، وارسیِ چشمی): درست ۳۹ → ۴۰، غلط ۹ → ۷، بیشکل ۷ → ۵ — در این اندازهی نمونه معنیدار نیست؛
|
||||||
|
برآوردِ ۹۵٪: درست ۵۰–۷۳٪، غلط ۵–۲۱٪. ۴۰ مسئلهی هندسیِ جدا: ۲۲ درست، ۱۲ قابلِ قبول، ۴ غلط. خرج ≈ ۰٫۷ دلار
|
||||||
|
(به اجازهی صاحبِ پروژه تا ~۱ دلار). اسکریپتِ سنجش در scratchpad بود، نه در ریپو. پرامپت `tutor-v6-diagrams`. سنجش روی ۹۶ مسئلهی واقعیِ کتاب: ۴۵ از ۴۸ شکلِ
|
||||||
معتبر (دورِ ۲)، ۰ از ۸ برای سوالِ بیشکل؛ وارسیِ چشمیِ ۸۳ شکل: ۶۴ درست، ۳ ضعیف، ۱۶ غلط.
|
معتبر (دورِ ۲)، ۰ از ۸ برای سوالِ بیشکل؛ وارسیِ چشمیِ ۸۳ شکل: ۶۴ درست، ۳ ضعیف، ۱۶ غلط.
|
||||||
اصلاحهای بعد از آن (خط از معادله، کسرِ مقایسهای، محرکِ ساعت، …) روی مدل سنجیده نشدهاند.
|
اصلاحهای بعد از آن (خط از معادله، کسرِ مقایسهای، محرکِ ساعت، …) روی مدل سنجیده نشدهاند.
|
||||||
- **فرانت آزمونِ جزیره را بازی میکند** (`/nodes/{id}/quiz`); خانه خودِ مسیر است با «شروع».
|
- **فرانت آزمونِ جزیره را بازی میکند** (`/nodes/{id}/quiz`); خانه خودِ مسیر است با «شروع».
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
متناهی و محدود، متنِ کوتاه. تکهی نامعتبر بیصدا حذف میشود؛ متنِ پاسخ هیچوقت
|
متناهی و محدود، متنِ کوتاه. تکهی نامعتبر بیصدا حذف میشود؛ متنِ پاسخ هیچوقت
|
||||||
بهخاطرِ شکل از دست نمیرود. آزموده نشده: رفتارِ مدلهای دیگر جز gpt-4.1-mini.
|
بهخاطرِ شکل از دست نمیرود. آزموده نشده: رفتارِ مدلهای دیگر جز gpt-4.1-mini.
|
||||||
"""
|
"""
|
||||||
|
import ast
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
@@ -46,9 +47,12 @@ _HEAD = (
|
|||||||
'قالبِ بلوکِ <diagram>: فقط یک JSON، کلیدها انگلیسی، برچسبها فارسی یا ریاضی. هر شکل "kind" و '
|
'قالبِ بلوکِ <diagram>: فقط یک JSON، کلیدها انگلیسی، برچسبها فارسی یا ریاضی. هر شکل "kind" و '
|
||||||
'"title"ِ کوتاهِ فارسی دارد. مختصاتی را که قابلِ حساب است خودت حساب نکن؛ بگو «چه»، اپ «کجا» را '
|
'"title"ِ کوتاهِ فارسی دارد. مختصاتی را که قابلِ حساب است خودت حساب نکن؛ بگو «چه»، اپ «کجا» را '
|
||||||
'دقیق حساب میکند. رنگ فقط: blue red green orange purple teal pink gray black yellow. '
|
'دقیق حساب میکند. رنگ فقط: blue red green orange purple teal pink gray black yellow. '
|
||||||
'در متنِ پاسخ به شکل اشاره کن («در شکلِ پایین…») با همان نامها. یک شکلِ روشن بهتر از شکلِ شلوغ.'
|
'در متنِ پاسخ به شکل اشاره کن («در شکلِ پایین…») با همان نامها. یک شکلِ روشن بهتر از شکلِ شلوغ. '
|
||||||
|
'شکلی که به فهمیدن کمک نمیکند (تبدیلِ واحد، ضرب یا تقسیمِ ساده، مدتِ زمان) نگذار.'
|
||||||
)
|
)
|
||||||
_PLANE = (
|
_PLANE = (
|
||||||
|
'مثلث و چهارضلعی (مستطیل، مربع، متوازیالاضلاع، لوزی، ذوزنقه) را همیشه با triangle و quad بکش و مختصاتِ رأسهایشان را '
|
||||||
|
'هرگز خودت ننویس؛ polygon فقط وقتی مسئله خودش مختصاتِ رأسها را داده است.\n'
|
||||||
'plane (هندسه و مختصات): {"kind":"plane","title":"…","axes":false,"grid":false,'
|
'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"]},'
|
'"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'
|
'"H":{"foot":["C","A","B"]},"I":{"intersect":[["A","B"],["C","D"]]},"T":{"along":["A","B",0.25]}},"items":[…]}\n'
|
||||||
@@ -64,10 +68,20 @@ _PLANE = (
|
|||||||
'{"type":"arc","center":"O","r":2,"start":0,"end":60} · {"type":"sector",…} · '
|
'{"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":"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":"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":"regularPolygon","n":5,"r":2,"names":["A","B","C","D","E"]} (چندضلعیِ منتظم؛ رأسها را اپ میسازد و '
|
||||||
'با همین نامها قابلِ ارجاعاند؛ مختصاتِ رأسِ چندضلعیِ منتظم را هرگز خودت ننویس) · '
|
'با همین نامها قابلِ ارجاعاند؛ مختصاتِ رأسِ چندضلعیِ منتظم را هرگز خودت ننویس) · '
|
||||||
'{"type":"exteriorAngle","vertex":"B","from":"A","to":"C","label":"72°"} (زاویهی خارجیِ رأسِ B: اپ ضلعِ AB را از B '
|
'{"type":"exteriorAngle","vertex":"B","from":"A","to":"C","label":"72°"} (زاویهی خارجیِ رأسِ B: اپ ضلعِ AB را از B '
|
||||||
'امتداد میدهد و زاویهی بینِ امتداد و BC را میکشد) · '
|
'امتداد میدهد و زاویهی بینِ امتداد و BC را میکشد) · '
|
||||||
|
'برای دوران، تقارن، انتقال و تجانس همیشه هم شکلِ اصلی و هم تصویرش را بکش (با image؛ مختصاتِ تصویر را خودت ننویس) · '
|
||||||
'{"type":"image","of":["A","B","C"],"transform":{"rotate":{"center":"C","deg":-90}}} (نامِ تصویرها A′؛ '
|
'{"type":"image","of":["A","B","C"],"transform":{"rotate":{"center":"C","deg":-90}}} (نامِ تصویرها A′؛ '
|
||||||
'rotate مثبت=پادساعتگرد و «در جهتِ عقربهها» منفی؛ یا {"reflect":"x"|"y"|"y=x"|["A","B"]}، {"translate":[2,1]}، '
|
'rotate مثبت=پادساعتگرد و «در جهتِ عقربهها» منفی؛ یا {"reflect":"x"|"y"|"y=x"|["A","B"]}، {"translate":[2,1]}، '
|
||||||
'{"scale":{"center":"O","k":2}}) · {"type":"text","at":[1,2],"text":"…"}'
|
'{"scale":{"center":"O","k":2}}) · {"type":"text","at":[1,2],"text":"…"}'
|
||||||
@@ -98,9 +112,19 @@ _PARTS = (
|
|||||||
'"compare":[{"parts":5,"shaded":1,"label":"یکپنجم"},{"parts":10,"shaded":9}] که نوارهای هماندازه میکشد) · '
|
'"compare":[{"parts":5,"shaded":1,"label":"یکپنجم"},{"parts":10,"shaded":9}] که نوارهای هماندازه میکشد) · '
|
||||||
'grid (درصد، شمردنِ خانه): {"kind":"grid","title":"…","rows":5,"cols":5,"shaded":5} · '
|
'grid (درصد، شمردنِ خانه): {"kind":"grid","title":"…","rows":5,"cols":5,"shaded":5} · '
|
||||||
'groups (مدلِ ضرب و تقسیم): {"kind":"groups","title":"…","groups":3,"each":4,"item":"circle"|"square"|"star"}. '
|
'groups (مدلِ ضرب و تقسیم): {"kind":"groups","title":"…","groups":3,"each":4,"item":"circle"|"square"|"star"}. '
|
||||||
|
'کسری از یک کل («نیمی از زمین»، «یکسوم از کیک») با fraction یا grid است، نه groups. '
|
||||||
'کسرِ جبری (با x یا a) و ساده کردنِ عبارت شکل ندارد.'
|
'کسرِ جبری (با x یا a) و ساده کردنِ عبارت شکل ندارد.'
|
||||||
)
|
)
|
||||||
_CLOCK = 'clock: {"kind":"clock","title":"…","hour":3,"minute":25}'
|
_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 = (
|
_CHARTS = (
|
||||||
'bar | linechart | pie: {"kind":"bar","title":"…","labels":["سیب","پرتقال"],"values":[5,8],"unit":"نفر"} '
|
'bar | linechart | pie: {"kind":"bar","title":"…","labels":["سیب","پرتقال"],"values":[5,8],"unit":"نفر"} '
|
||||||
'(درصدِ دایرهای را اپ حساب میکند)'
|
'(درصدِ دایرهای را اپ حساب میکند)'
|
||||||
@@ -111,13 +135,15 @@ _TABLE = (
|
|||||||
)
|
)
|
||||||
_SETS = (
|
_SETS = (
|
||||||
'venn: {"kind":"venn","title":"…","sets":["A","B"],"regions":{"A":["1","2"],"AB":["3"],"B":["5"],"out":["6"]},'
|
'venn: {"kind":"venn","title":"…","sets":["A","B"],"regions":{"A":["1","2"],"AB":["3"],"B":["5"],"out":["6"]},'
|
||||||
'"shade":["AB"]} (ناحیه با نامِ مجموعههایی که در آناند؛ «A» یعنی فقط A؛ تا سه مجموعه) · '
|
'"shade":["AB"]} (ناحیه با نامِ مجموعههایی که در آناند؛ «A» یعنی فقط A؛ تا سه مجموعه؛ هر عضو فقط یک بار، و عضوِ '
|
||||||
|
'مشترک فقط در AB؛ پیش از نوشتن، عضویتِ هر عدد را در هر مجموعه بسنج) · '
|
||||||
'mapping (نمودارِ پیکانی): {"kind":"mapping","title":"…","left":["1","2"],"right":["3","5"],"arrows":[[0,0],[1,1]],'
|
'mapping (نمودارِ پیکانی): {"kind":"mapping","title":"…","left":["1","2"],"right":["3","5"],"arrows":[[0,0],[1,1]],'
|
||||||
'"leftLabel":"دامنه","rightLabel":"برد"}'
|
'"leftLabel":"دامنه","rightLabel":"برد"}'
|
||||||
)
|
)
|
||||||
_TREE = (
|
_TREE = (
|
||||||
'tree (احتمال، شمارش): {"kind":"tree","title":"…","root":{"label":"شروع","children":[{"label":"رو","p":"1/2",'
|
'tree (احتمال، شمارش): {"kind":"tree","title":"…","root":{"label":"شروع","children":[{"label":"رو","p":"1/2",'
|
||||||
'"children":[…]},…]}} (حداکثر سه سطح)'
|
'"children":[…]},…]}} (حداکثر سه سطح) · چرخندهی رنگی (فرفره): {"kind":"pie","title":"…","labels":["قرمز","آبی"],'
|
||||||
|
'"values":[3,1]} با سهمِ هر رنگ'
|
||||||
)
|
)
|
||||||
_SOLID = (
|
_SOLID = (
|
||||||
'solid: {"kind":"solid","title":"…","shape":"cube"|"cuboid"|"prism"|"pyramid"|"cylinder"|"cone"|"sphere"|"hemisphere",'
|
'solid: {"kind":"solid","title":"…","shape":"cube"|"cuboid"|"prism"|"pyramid"|"cylinder"|"cone"|"sphere"|"hemisphere",'
|
||||||
@@ -130,7 +156,7 @@ _W = lambda words: re.compile('|'.join(words)) # noqa: E731
|
|||||||
_GEOMETRY_WORDS = (r'مثلث', r'دایره(?!ای)', r'زاویه', r'ضلع', r'چندضلعی', r'مربع', r'مستطیل', r'لوزی', r'ذوزنقه',
|
_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'بردار', r'دوران', r'انتقال', r'تقارن', r'قرینه', r'بازتاب', r'مختصات',
|
||||||
r'نقطه', r'سینوس', r'کسینوس', r'تانژانت', r'مثلثاتی', r'sin', r'cos', r'tan', 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*راست',
|
_GRAPH_WORDS = (r'نمودار(?![\u0650\u200c]?\s*(?:ستونی|دایرهای|میلهای|درختی|پیکانی|ون|خط\s*شکسته))', r'تابع', r'سهمی', r'خط\s*راست',
|
||||||
r'شیب', r'مجانب', r'مشتق', r'اکسترمم', r'ماکزیمم', r'مینیمم', r'صعودی', r'نزولی', r'پیوسته',
|
r'شیب', r'مجانب', r'مشتق', r'اکسترمم', r'ماکزیمم', r'مینیمم', r'صعودی', r'نزولی', r'پیوسته',
|
||||||
r'حد\b', r'\blim', r'نامعادله', r'مماس', r'log', r'f\(x\)', r'y\s*=')
|
r'حد\b', r'\blim', r'نامعادله', r'مماس', r'log', r'f\(x\)', r'y\s*=')
|
||||||
@@ -142,11 +168,12 @@ SECTIONS = [
|
|||||||
# «یکپنجم»، «نُهدهم»، «سهچهارم»: کسر با کلمه، که کتابِ دبستان همینطور مینویسد
|
# «یکپنجم»، «نُهدهم»، «سهچهارم»: کسر با کلمه، که کتابِ دبستان همینطور مینویسد
|
||||||
r'(?:یک|دو|سه|چهار|پنج|شش|هفت|هشت|نه|نُه|ده)[\u200c\s]?(?:دوم|سوم|چهارم|پنجم|ششم|هفتم|هشتم|نهم|دهم)(?!ین)')), _PARTS),
|
r'(?:یک|دو|سه|چهار|پنج|شش|هفت|هشت|نه|نُه|ده)[\u200c\s]?(?:دوم|سوم|چهارم|پنجم|ششم|هفتم|هشتم|نهم|دهم)(?!ین)')), _PARTS),
|
||||||
# «۶ ساعت مطالعه» مدت است، نه ساعتِ دیواری؛ فقط عقربه یا «ساعت … و … دقیقه»
|
# «۶ ساعت مطالعه» مدت است، نه ساعتِ دیواری؛ فقط عقربه یا «ساعت … و … دقیقه»
|
||||||
(_W((r'عقربه', r'ساعت\s*[0-9۰-۹]+\s*(?::|و)\s*[0-9۰-۹]+', r'ساعت\s*چند')), _CLOCK),
|
(_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'میلهای', r'دایرهای', r'خط\s*شکسته', r'میانگین', r'آمار', r'فراوانی')), _CHARTS),
|
||||||
(_W((r'جدول', r'تعیین\s*علامت')), _TABLE),
|
(_W((r'جدول', r'تعیین\s*علامت')), _TABLE),
|
||||||
(_W((r'مجموعه', r'\bون\b', r'اشتراک', r'اجتماع', r'پیکانی', r'دامنه', r'برد\b', r'زوج\s*مرتب')), _SETS),
|
(_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'درختی')), _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'استوانه', r'مخروط', r'کره', r'حجم', r'نیمکره')), _SOLID),
|
||||||
]
|
]
|
||||||
DIAGRAM_SPEC = '\n'.join([_HEAD] + [text for _, text in SECTIONS])
|
DIAGRAM_SPEC = '\n'.join([_HEAD] + [text for _, text in SECTIONS])
|
||||||
@@ -194,6 +221,21 @@ def asked_hint_for(question: str) -> str:
|
|||||||
return '\n'.join([ASKED_HINT, DIAGRAM_SPEC])
|
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:
|
def hint_for(question: str) -> str:
|
||||||
"""یادآوری و فقط آن بخشهایی از کاتالوگ که به همین سوال میخورند؛ سوالِ بیشکل → ''."""
|
"""یادآوری و فقط آن بخشهایی از کاتالوگ که به همین سوال میخورند؛ سوالِ بیشکل → ''."""
|
||||||
text = question or ''
|
text = question or ''
|
||||||
@@ -309,6 +351,8 @@ def _point_def(value):
|
|||||||
if not (isinstance(a, (list, tuple)) and len(a) == 3):
|
if not (isinstance(a, (list, tuple)) and len(a) == 3):
|
||||||
raise ValueError('along')
|
raise ValueError('along')
|
||||||
return {'along': [_ref(a[0]), _ref(a[1]), _num(a[2], -10, 10)]}
|
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:
|
if 'fx' in value:
|
||||||
e, x = value['fx']
|
e, x = value['fx']
|
||||||
return {'fx': [_expr(e), _num(x)]}
|
return {'fx': [_expr(e), _num(x)]}
|
||||||
@@ -353,6 +397,49 @@ def _range(item, lo_key='from', hi_key='to', lo=-5.0, hi=5.0):
|
|||||||
return a, b
|
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:
|
def _plane_item(item: dict) -> dict:
|
||||||
kind = item.get('type')
|
kind = item.get('type')
|
||||||
out: dict = {'type': kind}
|
out: dict = {'type': kind}
|
||||||
@@ -467,6 +554,70 @@ def _plane_item(item: dict) -> dict:
|
|||||||
names = item.get('names')
|
names = item.get('names')
|
||||||
if isinstance(names, list):
|
if isinstance(names, list):
|
||||||
out['names'] = [_label(n, 6) for n in names[:12]]
|
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':
|
elif kind == 'text':
|
||||||
out.update(at=_xy(item['at']), text=_label(item.get('text')))
|
out.update(at=_xy(item['at']), text=_label(item.get('text')))
|
||||||
if not out['text']:
|
if not out['text']:
|
||||||
@@ -697,6 +848,19 @@ def validate(spec) -> dict | None:
|
|||||||
if isinstance(names, list) and len(names) == len(sets):
|
if isinstance(names, list) and len(names) == len(sets):
|
||||||
out['names'] = [_label(n, 12) for n in names]
|
out['names'] = [_label(n, 12) for n in names]
|
||||||
return out
|
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':
|
if kind == 'mapping':
|
||||||
left, right = _strings(spec.get('left'), 10, 10), _strings(spec.get('right'), 10, 10)
|
left, right = _strings(spec.get('left'), 10, 10), _strings(spec.get('right'), 10, 10)
|
||||||
arrows = []
|
arrows = []
|
||||||
@@ -771,6 +935,59 @@ def _repair(body: str) -> str:
|
|||||||
return _PI_VALUE.sub(pi, body)
|
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):
|
def _normalize(obj):
|
||||||
"""
|
"""
|
||||||
شکلهایی که مدل با کمی فاصله از قالب نوشت ولی منظورش روشن است:
|
شکلهایی که مدل با کمی فاصله از قالب نوشت ولی منظورش روشن است:
|
||||||
@@ -781,6 +998,27 @@ def _normalize(obj):
|
|||||||
if not isinstance(obj, dict):
|
if not isinstance(obj, dict):
|
||||||
return obj
|
return obj
|
||||||
kind = obj.get('kind')
|
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:
|
if kind is None and 'type' in obj:
|
||||||
return {'kind': 'plane', 'title': obj.get('title', ''), 'points': {}, 'items': [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:
|
if kind in ('function', 'piecewise') and 'expr' in obj or kind == 'function' and 'pieces' in obj:
|
||||||
@@ -820,7 +1058,7 @@ def parse(body: str) -> dict | None:
|
|||||||
if body.startswith('```'):
|
if body.startswith('```'):
|
||||||
body = body.strip('`').removeprefix('json').strip()
|
body = body.strip('`').removeprefix('json').strip()
|
||||||
try:
|
try:
|
||||||
return validate(_normalize(json.loads(_repair(body))))
|
return validate(_normalize(_loads(body)))
|
||||||
except (json.JSONDecodeError, TypeError, ValueError):
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
logger.info('diagram: JSONِ نامعتبر از مدل، دور ریخته شد')
|
logger.info('diagram: JSONِ نامعتبر از مدل، دور ریخته شد')
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ _RAW_MARKUP = re.compile(r'\\[A-Za-z(\[]|\$|\*\*|^\s*#{1,6}\s|^\s*\|\s*:?-{3,}',
|
|||||||
# بشود گفت «کدام نسخهی پرامپت جوابِ بهتری میداد» — بدونِ این، همهی فیدبکها
|
# بشود گفت «کدام نسخهی پرامپت جوابِ بهتری میداد» — بدونِ این، همهی فیدبکها
|
||||||
# در یک کاسه میریزند و مقایسهی نسخهها ممکن نیست. کشِ پاسخ هم با همین نسخه
|
# در یک کاسه میریزند و مقایسهی نسخهها ممکن نیست. کشِ پاسخ هم با همین نسخه
|
||||||
# کلید میخورد، پس جوابِ نسخهی قبلی بعد از دیپلوی دوباره سرو نمیشود.
|
# کلید میخورد، پس جوابِ نسخهی قبلی بعد از دیپلوی دوباره سرو نمیشود.
|
||||||
PROMPT_VERSION = 'tutor-v8-asked-figure'
|
PROMPT_VERSION = 'tutor-v10-shape-anchor'
|
||||||
|
|
||||||
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
|
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
|
||||||
_MIDDLE = {'هفتم', 'هشتم', 'نهم'}
|
_MIDDLE = {'هفتم', 'هشتم', 'نهم'}
|
||||||
@@ -259,12 +259,16 @@ def build_messages(question: str, context_chunks: list[dict] | None,
|
|||||||
messages.append({'role': role, 'content': text[:_HISTORY_CHARS]})
|
messages.append({'role': role, 'content': text[:_HISTORY_CHARS]})
|
||||||
# سوالِ قبلیِ کاربر: «اگه بهجای ۶ بذاریم ۴ چی؟» کلمهی شکلدار ندارد، ولی ادامهی
|
# سوالِ قبلیِ کاربر: «اگه بهجای ۶ بذاریم ۴ چی؟» کلمهی شکلدار ندارد، ولی ادامهی
|
||||||
# همان مسئلهی شکلدار است و شکلش باید با عددِ تازه دوباره کشیده شود.
|
# همان مسئلهی شکلدار است و شکلش باید با عددِ تازه دوباره کشیده شود.
|
||||||
previous = next((t.get('text') or '' for t in reversed(history or []) if t.get('role') == 'user'), '')
|
previous = _previous_question(history)
|
||||||
messages.append({'role': 'user', 'content': _user_prompt(
|
messages.append({'role': 'user', 'content': _user_prompt(
|
||||||
question, contexts, focus, primary=grade_tier(grade_title) == 'primary', previous=previous)})
|
question, contexts, focus, primary=grade_tier(grade_title) == 'primary', previous=previous)})
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def _previous_question(history) -> str:
|
||||||
|
return next((t.get('text') or '' for t in reversed(history or []) if t.get('role') == 'user'), '')
|
||||||
|
|
||||||
|
|
||||||
def generate_answer(question: str, context_chunks: list[dict] | None,
|
def generate_answer(question: str, context_chunks: list[dict] | None,
|
||||||
grade_title: str = '', history: list[dict] | None = None,
|
grade_title: str = '', history: list[dict] | None = None,
|
||||||
model: str | None = None, focus: str = '') -> tuple[str, list[str], dict]:
|
model: str | None = None, focus: str = '') -> tuple[str, list[str], dict]:
|
||||||
@@ -290,6 +294,8 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
|
|||||||
raw = (completion.choices[0].message.content or '').strip()
|
raw = (completion.choices[0].message.content or '').strip()
|
||||||
# بلوکِ شکل پیش از to_plain جدا میشود؛ to_plain کسرها و «*» را در JSON عوض میکرد.
|
# بلوکِ شکل پیش از to_plain جدا میشود؛ to_plain کسرها و «*» را در JSON عوض میکرد.
|
||||||
raw, diagram = diagram_mod.extract(raw)
|
raw, diagram = diagram_mod.extract(raw)
|
||||||
|
# سوالِ ادامهای («حالا ۱۰ دقیقه بعد؟») با سوالِ قبلی سنجیده میشود
|
||||||
|
diagram = diagram_mod.fits(f'{question}\n{focus}\n{_previous_question(history)}', diagram)
|
||||||
answer = to_plain(raw, fraction_word=grade_tier(grade_title) == 'primary')
|
answer = to_plain(raw, fraction_word=grade_tier(grade_title) == 'primary')
|
||||||
if not answer:
|
if not answer:
|
||||||
answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.'
|
answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.'
|
||||||
@@ -351,7 +357,7 @@ def stream_answer(question: str, context_chunks: list[dict] | None,
|
|||||||
yield clean
|
yield clean
|
||||||
tail = sanitizer.feed(splitter.flush()) + sanitizer.flush()
|
tail = sanitizer.feed(splitter.flush()) + sanitizer.flush()
|
||||||
if result is not None:
|
if result is not None:
|
||||||
result['diagram'] = splitter.diagram
|
result['diagram'] = diagram_mod.fits(f'{question}\n{focus}\n{_previous_question(history)}', splitter.diagram)
|
||||||
if tail.strip():
|
if tail.strip():
|
||||||
yield tail
|
yield tail
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ class DiagramThroughTheApiTests(TestCase):
|
|||||||
|
|
||||||
message = Message.objects.get(role='assistant', conversation__user_id=self.user.id)
|
message = Message.objects.get(role='assistant', conversation__user_id=self.user.id)
|
||||||
self.assertEqual(message.diagram, done['diagram'])
|
self.assertEqual(message.diagram, done['diagram'])
|
||||||
self.assertEqual(message.prompt_version, 'tutor-v8-asked-figure')
|
self.assertEqual(message.prompt_version, 'tutor-v10-shape-anchor')
|
||||||
history = self.client.get(f'/api/ai/conversations/{done["conversationId"]}/messages').json()
|
history = self.client.get(f'/api/ai/conversations/{done["conversationId"]}/messages').json()
|
||||||
self.assertEqual(history['messages'][-1]['diagram']['kind'], 'plane')
|
self.assertEqual(history['messages'][-1]['diagram']['kind'], 'plane')
|
||||||
self.assertIsNone(history['messages'][0]['diagram'])
|
self.assertIsNone(history['messages'][0]['diagram'])
|
||||||
@@ -356,7 +356,7 @@ class FollowUpHintTests(SimpleTestCase):
|
|||||||
from .services.diagram import ASKED_HINT, DIAGRAM_SPEC, asked_hint_for
|
from .services.diagram import ASKED_HINT, DIAGRAM_SPEC, asked_hint_for
|
||||||
from .services.generation import build_messages
|
from .services.generation import build_messages
|
||||||
|
|
||||||
last = build_messages('یه باغچه ۵ در ۸ متره، شکلش رو برام بکش', None, 'ششم')[-1]['content']
|
last = build_messages('یه جعبهی کادو دارم، شکلش رو برام بکش', None, 'ششم')[-1]['content']
|
||||||
self.assertIn(ASKED_HINT, last)
|
self.assertIn(ASKED_HINT, last)
|
||||||
self.assertIn(DIAGRAM_SPEC, last)
|
self.assertIn(DIAGRAM_SPEC, last)
|
||||||
for q in ('مشکل دارم با این سوال', 'میخوام یه سوال بپرسم', '۳۴۵ × ۱۲'):
|
for q in ('مشکل دارم با این سوال', 'میخوام یه سوال بپرسم', '۳۴۵ × ۱۲'):
|
||||||
@@ -390,3 +390,73 @@ class ThirdRoundTests(SimpleTestCase):
|
|||||||
|
|
||||||
self.assertIn(_PARTS, hint_for('یکپنجم و نُهدهم رو با هم مقایسه کن، کدوم بزرگتره؟'))
|
self.assertIn(_PARTS, hint_for('یکپنجم و نُهدهم رو با هم مقایسه کن، کدوم بزرگتره؟'))
|
||||||
self.assertNotIn(_PARTS, hint_for('رتبهی پنجمین نفر در مسابقه چند است؟'))
|
self.assertNotIn(_PARTS, hint_for('رتبهی پنجمین نفر در مسابقه چند است؟'))
|
||||||
|
|
||||||
|
|
||||||
|
class FourthRoundTests(SimpleTestCase):
|
||||||
|
"""سنجشِ ۲۰۲۶-۰۹-۲۹ (۶۴ مسئلهی تازه): مثلث و چهارضلعی از روی داده، الگوی شکلی، و لغزشهای JSON."""
|
||||||
|
|
||||||
|
def test_triangle_and_quad_from_problem_data(self):
|
||||||
|
from .services.diagram import parse
|
||||||
|
tri = parse('{"kind":"plane","items":[{"type":"triangle","names":["A","B","C"],"sides":{"AB":6,"AC":8},'
|
||||||
|
'"angles":{"A":90},"labels":{"BC":"x","Z":"?"},"altitude":"C"}]}')['items'][0]
|
||||||
|
self.assertEqual(tri['sides'], {'AB': 6.0, 'AC': 8.0})
|
||||||
|
self.assertEqual(tri['labels'], {'BC': 'x'})
|
||||||
|
self.assertEqual(tri['altitude'], 'C')
|
||||||
|
# زاویههایی که جمعشان ۱۸۰ یا بیشتر است مثلث نمیسازند
|
||||||
|
self.assertIsNone(parse('{"kind":"plane","items":[{"type":"triangle","angles":{"A":100,"B":90}}]}'))
|
||||||
|
quad = parse('{"kind":"plane","items":[{"type":"quad","shape":"rhombus","d1":8,"d2":6}]}')['items'][0]
|
||||||
|
self.assertEqual((quad['names'], quad['d1'], quad['d2']), (['A', 'B', 'C', 'D'], 8.0, 6.0))
|
||||||
|
self.assertIsNone(parse('{"kind":"plane","items":[{"type":"quad","shape":"kite","w":3}]}'))
|
||||||
|
|
||||||
|
def test_pattern_kind(self):
|
||||||
|
from .services.diagram import _PATTERN, hint_for, parse
|
||||||
|
spec = parse('{"kind":"pattern","layout":"triangle","unit":"dot","stages":[1,2,3,40],"next":true}')
|
||||||
|
self.assertEqual(spec['stages'], [1, 2, 3, 12])
|
||||||
|
self.assertIn(_PATTERN, hint_for('در الگوی زیر شکلِ ۵ چند چوب کبریت دارد؟'))
|
||||||
|
|
||||||
|
def test_json_slips_seen_in_the_benchmark(self):
|
||||||
|
from .services.diagram import parse
|
||||||
|
# رشتهی بستهنشده پیش از }
|
||||||
|
self.assertEqual(parse('{"kind":"plane","items":[{"type":"segment","from":"A","to":"B","color":"orange}]}')
|
||||||
|
['items'][0]['color'], 'orange')
|
||||||
|
# کلیدِ تکراریِ points: هر دو نگه داشته میشوند
|
||||||
|
spec = parse('{"kind":"plane","points":{"A":[0,0]},"items":[{"type":"point","point":"A"}],"points":{"B":[1,1]}}')
|
||||||
|
self.assertEqual(set(spec['points']), {'A', 'B'})
|
||||||
|
# عددِ کسری بیگیومه؛ برچسبِ «۱۲/۱۸» دست نمیخورد
|
||||||
|
spec = parse('{"kind":"plane","items":[{"type":"function","expr":"x^2","from":-5/3,"to":5/3,"label":"12/18"}]}')
|
||||||
|
self.assertAlmostEqual(spec['items'][0]['from'], -5 / 3, places=4)
|
||||||
|
self.assertEqual(spec['items'][0]['label'], '12/18')
|
||||||
|
self.assertEqual(parse('{"kind":"compare","compare":[{"parts":5,"shaded":1},{"parts":10,"shaded":9}]}')['kind'],
|
||||||
|
'fraction')
|
||||||
|
|
||||||
|
def test_real_world_geometry_words_get_the_plane_section(self):
|
||||||
|
from .services.diagram import _PLANE, hint_for
|
||||||
|
for q in ('نردبانی به طولِ ۵ متر به دیوار تکیه داده شده', 'زمینی به طولِ ۲۰ و عرضِ ۱۲ متر'):
|
||||||
|
self.assertIn(_PLANE, hint_for(q), q)
|
||||||
|
|
||||||
|
|
||||||
|
class FifthRoundTests(SimpleTestCase):
|
||||||
|
"""نگهبانِ ساعت، نقطهی انتقال، جای شکل."""
|
||||||
|
|
||||||
|
def test_clock_only_for_telling_time(self):
|
||||||
|
from .services.diagram import _CLOCK, fits, hint_for
|
||||||
|
clock = {'kind': 'clock', 'title': '', 'hour': 0, 'minute': 12}
|
||||||
|
self.assertIsNone(fits('۲۰ درصد یک ساعت چند دقیقه است؟', clock))
|
||||||
|
self.assertEqual(fits('ساعت ۳ و ۲۵ دقیقه را روی ساعت نشان بده', clock), clock)
|
||||||
|
self.assertNotIn(_CLOCK, hint_for('۲۰ درصد یک ساعت چند دقیقه است؟'))
|
||||||
|
self.assertEqual(fits('حالا ۱۰ دقیقه بعد؟\n\nساعت ۳ و ۲۵ دقیقه', clock), clock)
|
||||||
|
self.assertIsNone(fits('x', None))
|
||||||
|
|
||||||
|
def test_translate_point_and_shape_anchor(self):
|
||||||
|
from .services.diagram import parse
|
||||||
|
spec = parse('{"kind":"plane","points":{"A":[0,0],"B":{"translate":[2,3],"from":"A"}},'
|
||||||
|
'"items":[{"type":"quad","shape":"square","side":2,"at":[5,0]}]}')
|
||||||
|
self.assertEqual(spec['points']['B'], {'translate': [2.0, 3.0], 'from': 'A'})
|
||||||
|
self.assertEqual(spec['items'][0]['at'], [5.0, 0.0])
|
||||||
|
|
||||||
|
def test_shape_name_as_kind_with_aliases(self):
|
||||||
|
from .services.diagram import parse
|
||||||
|
item = parse('{"kind":"trapezoid","title":"ذوزنقه","bases":[10,6],"height":4,"color":"blue"}')['items'][0]
|
||||||
|
self.assertEqual((item['type'], item['shape'], item['w'], item['top'], item['h']), ('quad', 'trapezoid', 10.0, 6.0, 4.0))
|
||||||
|
item = parse('{"kind":"rectangle","length":8,"width":5}')['items'][0]
|
||||||
|
self.assertEqual((item['w'], item['h']), (8.0, 5.0))
|
||||||
|
|||||||
Reference in New Issue
Block a user