tutor-v7-diagram-kit: anchor short follow-ups to the previous question, new diagram pieces in the prompt

A real chat answered "what if 6 were 4?" with a semicircle problem from the
retrieved book excerpt instead of the parabola asked a moment before. A short
follow-up now carries the previous user question next to it. The catalog
teaches line equations as written, regularPolygon, exteriorAngle, graphs only
through function items and tangents through tangent; fraction words (یک‌پنجم،
نُه‌دهم) pull the fraction section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 02:37:50 +03:30
parent 14a4d9cbf1
commit 731dea9bb1
4 changed files with 85 additions and 10 deletions

View File

@@ -58,18 +58,24 @@ _PLANE = (
'items (همه color، dashed و label اختیاری دارند): {"type":"point","point":"A"} · ' 'items (همه color، dashed و label اختیاری دارند): {"type":"point","point":"A"} · '
'{"type":"segment","from":"A","to":"B","label":"۵","ticks":1} (ticks علامتِ ضلعِ مساوی ۱ تا ۳) · ' '{"type":"segment","from":"A","to":"B","label":"۵","ticks":1} (ticks علامتِ ضلعِ مساوی ۱ تا ۳) · '
'{"type":"vector","from":"A","to":"B"} · {"type":"line","through":["A","B"]} (خطِ بی‌پایان؛ ضلع و قطر segment‌اند) · ' '{"type":"vector","from":"A","to":"B"} · {"type":"line","through":["A","B"]} (خطِ بی‌پایان؛ ضلع و قطر segment‌اند) · '
'{"type":"line","eq":[3,5,4]} یعنی خطِ 3x + 5y = 4 (ضریب‌ها را عیناً از صورتِ مسئله بردار؛ خودت شیب حساب نکن) · ' '{"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":"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":"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 ۱ تا ۳ برای زاویه‌های مساوی) · '
'زاویه‌ی خارجیِ رأسِ 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":"rightAngle","vertex":"H","from":"A","to":"B"} · {"type":"polygon","points":["A","B","C"]} · '
'{"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 را می‌کشد) · '
'{"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":"…"}'
) )
_GRAPH = ( _GRAPH = (
'نمودارِ تابع (در plane با "axes":true؛ "trig":true یعنی محورِ x با مضربِ π؛ "xRange"/"yRange" اختیاری): ' 'نمودارِ تابع (در 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",' '{"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 ' '"marks":["roots","vertex","axis","yIntercept"]} (marks: roots ریشه، vertex رأس، extrema ماکزیمم/مینیمم، axis '
'محورِ تقارن؛ اپ دقیق حساب می‌کند) · {"type":"piecewise","pieces":[{"expr":"x+1","from":-2,"to":1,' 'محورِ تقارن؛ اپ دقیق حساب می‌کند) · {"type":"piecewise","pieces":[{"expr":"x+1","from":-2,"to":1,'
@@ -132,7 +138,9 @@ SECTIONS = [
(_W(_GEOMETRY_WORDS + _GRAPH_WORDS), _PLANE), (_W(_GEOMETRY_WORDS + _GRAPH_WORDS), _PLANE),
(_W(_GRAPH_WORDS), _GRAPH), (_W(_GRAPH_WORDS), _GRAPH),
(_W((r'محور', r'بازه', r'نامعادله', r'واحد به')), _NUMBERLINE), (_W((r'محور', r'بازه', r'نامعادله', r'واحد به')), _NUMBERLINE),
(_W((r'کسر', r'تکه', r'پیتزا', r'کیک', r'درصد', r'خانه', r'گروه', r'نصف', r'چهارم')), _PARTS), (_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*چند')), _CLOCK), (_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'میله‌ای', r'دایره‌ای', r'خط\s*شکسته', r'میانگین', r'آمار', r'فراوانی')), _CHARTS),
@@ -243,6 +251,19 @@ def _expr(value) -> str:
return value.strip() 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): def _pair(value, conv):
if not (isinstance(value, (list, tuple)) and len(value) == 2): if not (isinstance(value, (list, tuple)) and len(value) == 2):
raise ValueError('pair') raise ValueError('pair')
@@ -327,7 +348,11 @@ def _plane_item(item: dict) -> dict:
if kind == 'segment' and item.get('ticks') is not None: if kind == 'segment' and item.get('ticks') is not None:
out['ticks'] = _clamp(item['ticks'], 1, 3) out['ticks'] = _clamp(item['ticks'], 1, 3)
elif kind == 'line': elif kind == 'line':
if item.get('eq') is not None: 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'] eq = item['eq']
if not (isinstance(eq, (list, tuple)) and len(eq) == 3): if not (isinstance(eq, (list, tuple)) and len(eq) == 3):
raise ValueError('eq') raise ValueError('eq')
@@ -360,6 +385,20 @@ def _plane_item(item: dict) -> dict:
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: if kind == 'angle' and item.get('arcs') is not None:
out['arcs'] = _clamp(item['arcs'], 1, 3) 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': elif kind == 'polygon':
points = item['points'] points = item['points']
if not isinstance(points, list) or not 3 <= len(points) <= 12: if not isinstance(points, list) or not 3 <= len(points) <= 12:

View File

@@ -38,7 +38,7 @@ _RAW_MARKUP = re.compile(r'\\[A-Za-z(\[]|\$|\*\*|^\s*#{1,6}\s|^\s*\|\s*:?-{3,}',
# بشود گفت «کدام نسخه‌ی پرامپت جوابِ بهتری می‌داد» — بدونِ این، همه‌ی فیدبک‌ها # بشود گفت «کدام نسخه‌ی پرامپت جوابِ بهتری می‌داد» — بدونِ این، همه‌ی فیدبک‌ها
# در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست. کشِ پاسخ هم با همین نسخه # در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست. کشِ پاسخ هم با همین نسخه
# کلید می‌خورد، پس جوابِ نسخه‌ی قبلی بعد از دیپلوی دوباره سرو نمی‌شود. # کلید می‌خورد، پس جوابِ نسخه‌ی قبلی بعد از دیپلوی دوباره سرو نمی‌شود.
PROMPT_VERSION = 'tutor-v6-diagrams' PROMPT_VERSION = 'tutor-v7-diagram-kit'
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'} _PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
_MIDDLE = {'هفتم', 'هشتم', 'نهم'} _MIDDLE = {'هفتم', 'هشتم', 'نهم'}
@@ -178,6 +178,11 @@ _SLASH_READING = (
def _user_prompt(question: str, contexts: list[str], focus: str = '', def _user_prompt(question: str, contexts: list[str], focus: str = '',
primary: bool = False, previous: str = '') -> str: primary: bool = False, previous: str = '') -> str:
asked = f'سوالِ دانش‌آموز: {question}' asked = f'سوالِ دانش‌آموز: {question}'
# سوالِ کوتاهِ ادامه‌ای («اگه به‌جای ۶ بذاریم ۴؟») بدونِ لنگر، مدل را سراغِ تکه‌ی کتابِ
# نامربوط می‌برد (چتِ واقعی، ۲۰۲۶-۰۹-۲۹: جوابِ یک «نیم‌دایره» به‌جای همان سهمی).
if previous and len(question) <= 80:
asked = (f'{asked}\n(این ادامه‌ی گفتگوست؛ سوالِ قبلیِ دانش‌آموز: «{previous[:300]}». '
'اگر به آن مربوط است، همان مسئله را با تغییرِ خواسته‌شده حل کن.)')
shape_hint = (diagram_mod.hint_for(f'{question}\n{focus}') shape_hint = (diagram_mod.hint_for(f'{question}\n{focus}')
or diagram_mod.follow_up_hint_for(question, previous)) or diagram_mod.follow_up_hint_for(question, previous))
if shape_hint: if shape_hint:
@@ -188,7 +193,7 @@ def _user_prompt(question: str, contexts: list[str], focus: str = '',
if focus: if focus:
asked = f'{focus}\n\n{asked}' asked = f'{focus}\n\n{asked}'
if not contexts: if not contexts:
return asked if (focus or slash_rule or shape_hint) else question return asked if (focus or slash_rule or shape_hint or previous) else question
# این جمله برای کتاب‌های دبیرستان نوشته شده بود (۳/۱۰۴ = 3.104) ولی به همه # این جمله برای کتاب‌های دبیرستان نوشته شده بود (۳/۱۰۴ = 3.104) ولی به همه
# می‌رفت، و gpt-4.1-mini با آن هر کسرِ دبستانی را ممیز می‌خواند. # می‌رفت، و gpt-4.1-mini با آن هر کسرِ دبستانی را ممیز می‌خواند.
slash_note = ( slash_note = (

View File

@@ -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-v6-diagrams') self.assertEqual(message.prompt_version, 'tutor-v7-diagram-kit')
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'])
@@ -351,3 +351,32 @@ class FollowUpHintTests(SimpleTestCase):
last = build_messages('حالا دایره‌ای به شعاعِ ۲', None, 'نهم', history=history)[-1]['content'] last = build_messages('حالا دایره‌ای به شعاعِ ۲', None, 'نهم', history=history)[-1]['content']
self.assertIn(DIAGRAM_HINT, last) self.assertIn(DIAGRAM_HINT, last)
self.assertNotIn(FOLLOW_UP_HINT, last) self.assertNotIn(FOLLOW_UP_HINT, last)
class ThirdRoundTests(SimpleTestCase):
"""از چتِ واقعی با gpt-4.1-mini (۲۰۲۶-۰۹-۲۹): خط، چندضلعیِ منتظم، زاویه‌ی خارجی، کسرِ کلمه‌ای."""
def test_line_as_written_equation(self):
spec = validate({'kind': 'plane', 'items': [
{'type': 'line', 'equation': '3x + 5y = 4'}, {'type': 'line', 'equation': 'y = 12x − 16'},
{'type': 'line', 'equation': 'y = sin(x)'}, {'type': 'line', 'equation': 'x + y'}]})
self.assertEqual([i.get('equation') for i in spec['items']], ['3x + 5y = 4', 'y = 12x - 16'])
def test_eq_array_is_general_form(self):
# عینِ خروجیِ مدل برای مماسِ y = 12x − 16
spec = validate({'kind': 'plane', 'items': [{'type': 'line', 'eq': [-12, 1, 16]}]})
self.assertEqual(spec['items'][0]['eq'], [-12.0, 1.0, 16.0])
def test_regular_polygon_and_exterior_angle(self):
spec = validate({'kind': 'plane', 'items': [
{'type': 'regularPolygon', 'n': 50, 'r': 2, 'names': ['A', 'B', 'C', 'D', 'E', 'bad name!']},
{'type': 'exteriorAngle', 'vertex': 'B', 'from': 'A', 'to': 'C', 'label': '72°'}]})
self.assertEqual(spec['items'][0]['n'], 12)
self.assertEqual(spec['items'][0]['names'], ['A', 'B', 'C', 'D', 'E'])
self.assertEqual(spec['items'][1]['type'], 'exteriorAngle')
def test_fraction_words_pull_the_fraction_section(self):
from .services.diagram import _PARTS, hint_for
self.assertIn(_PARTS, hint_for('یک‌پنجم و نُه‌دهم رو با هم مقایسه کن، کدوم بزرگ‌تره؟'))
self.assertNotIn(_PARTS, hint_for('رتبه‌ی پنجمین نفر در مسابقه چند است؟'))

View File

@@ -106,7 +106,9 @@ class TutorPromptTests(SimpleTestCase):
self.assertEqual(messages[0]['role'], 'system') self.assertEqual(messages[0]['role'], 'system')
self.assertEqual(len(messages), 1 + HISTORY_TURNS + 1) self.assertEqual(len(messages), 1 + HISTORY_TURNS + 1)
self.assertEqual(messages[-2], {'role': 'assistant', 'content': 'جواب 9'}) self.assertEqual(messages[-2], {'role': 'assistant', 'content': 'جواب 9'})
self.assertTrue(messages[-1]['content'].endswith('سوالِ دانش‌آموز: نفهمیدم')) self.assertIn('سوالِ دانش‌آموز: نفهمیدم', messages[-1]['content'])
# سوالِ کوتاهِ ادامه‌ای به سوالِ قبلیِ همان گفتگو لنگر می‌شود
self.assertIn('سوالِ قبلیِ دانش‌آموز: «سوال 8»', messages[-1]['content'])
self.assertIn('متنِ کتاب', messages[-1]['content']) self.assertIn('متنِ کتاب', messages[-1]['content'])
def test_reasoning_models_get_no_temperature(self): def test_reasoning_models_get_no_temperature(self):