diff --git a/rag/services/diagram.py b/rag/services/diagram.py index 44655ee..522f482 100644 --- a/rag/services/diagram.py +++ b/rag/services/diagram.py @@ -58,18 +58,24 @@ _PLANE = ( '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":"line","equation":"3x + 5y = 4"} خط از روی معادله، عیناً همان‌طور که در مسئله یا جوابت نوشته‌ای ' + '(«y = 12x − 16»، «x = 2.5» هم درست است؛ خودت شیب و نقطه حساب نکن) · ' '{"type":"ray","from":"A","through":"B"} · {"type":"circle","center":"O","r":2} یا "through":"A" · ' '{"type":"arc","center":"O","r":2,"start":0,"end":60} · {"type":"sector",…} · ' - '{"type":"angle","vertex":"B","from":"A","to":"C","label":"۴۰°","arcs":1} (arcs ۱ تا ۳ برای زاویه‌های مساوی؛ ' - 'زاویه‌ی خارجیِ رأسِ B: نقطه‌ی "E":{"along":["A","B",1.5]} روی امتدادِ AB، بعد angle از E تا C) · ' + '{"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":"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′؛ ' '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" اختیاری): ' + 'نمودارِ تابع (در plane با "axes":true؛ "trig":true یعنی محورِ x با مضربِ π؛ "xRange"/"yRange" اختیاری). ' + 'هر نمودار با item نوعِ function کشیده می‌شود؛ ریشه، رأس و اکسترمم را با marks بخواه، نه با نقطه‌ی دستی. ' + 'خطِ مماس همیشه با tangent: ' '{"type":"function","expr":"x^2-2*x-3","from":-2,"to":4,"label":"y = x² − 2x − 3",' '"marks":["roots","vertex","axis","yIntercept"]} (marks: roots ریشه، vertex رأس، extrema ماکزیمم/مینیمم، axis ' 'محورِ تقارن؛ اپ دقیق حساب می‌کند) · {"type":"piecewise","pieces":[{"expr":"x+1","from":-2,"to":1,' @@ -132,7 +138,9 @@ 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'تکه', 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'میله‌ای', r'دایره‌ای', r'خط\s*شکسته', r'میانگین', r'آمار', r'فراوانی')), _CHARTS), @@ -243,6 +251,19 @@ def _expr(value) -> str: return value.strip() +_LINEAR_OK = re.compile(r'^[0-9xy+\-=.\s/]*$') + + +def _linear_equation(value) -> str: + """«3x + 5y = 4»، «y = 12x − 16»: فقط x، y، عدد، + − = و ممیز؛ خواندنِ دقیقش با فرانت است.""" + if not isinstance(value, str): + raise ValueError('equation') + text = value.translate(_DIGITS).replace('−', '-').replace('–', '-').strip() + if not text or len(text) > 60 or text.count('=') != 1 or not _LINEAR_OK.match(text) or not re.search('[xy]', text): + raise ValueError('equation chars') + return text + + def _pair(value, conv): if not (isinstance(value, (list, tuple)) and len(value) == 2): raise ValueError('pair') @@ -327,7 +348,11 @@ def _plane_item(item: dict) -> dict: if kind == 'segment' and item.get('ticks') is not None: out['ticks'] = _clamp(item['ticks'], 1, 3) 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'] if not (isinstance(eq, (list, tuple)) and len(eq) == 3): 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'])}) if kind == 'angle' and item.get('arcs') is not None: out['arcs'] = _clamp(item['arcs'], 1, 3) + elif kind == 'regularPolygon': + out['n'] = _clamp(item.get('n', 5), 3, 12) + out['r'] = _num(item.get('r', 2), 0.01, MAX_COORD) + if item.get('center') is not None: + out['center'] = _ref(item['center']) + if item.get('start') is not None: + out['start'] = _num(item['start'], -360, 360) + names = item.get('names') + if isinstance(names, list): + clean = [n for n in names[:out['n']] if isinstance(n, str) and _NAME_OK.match(n)] + if clean: + out['names'] = clean + elif kind == 'exteriorAngle': + out.update({'vertex': _ref(item['vertex']), 'from': _ref(item['from']), 'to': _ref(item['to'])}) elif kind == 'polygon': points = item['points'] if not isinstance(points, list) or not 3 <= len(points) <= 12: diff --git a/rag/services/generation.py b/rag/services/generation.py index a7de4e2..d4371c5 100644 --- a/rag/services/generation.py +++ b/rag/services/generation.py @@ -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 = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'} _MIDDLE = {'هفتم', 'هشتم', 'نهم'} @@ -178,6 +178,11 @@ _SLASH_READING = ( def _user_prompt(question: str, contexts: list[str], focus: str = '', primary: bool = False, previous: str = '') -> str: 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}') or diagram_mod.follow_up_hint_for(question, previous)) if shape_hint: @@ -188,7 +193,7 @@ def _user_prompt(question: str, contexts: list[str], focus: str = '', if focus: asked = f'{focus}\n\n{asked}' 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) ولی به همه # می‌رفت، و gpt-4.1-mini با آن هر کسرِ دبستانی را ممیز می‌خواند. slash_note = ( diff --git a/rag/tests_diagram.py b/rag/tests_diagram.py index f492c59..21ae97e 100644 --- a/rag/tests_diagram.py +++ b/rag/tests_diagram.py @@ -130,7 +130,7 @@ class DiagramThroughTheApiTests(TestCase): message = Message.objects.get(role='assistant', conversation__user_id=self.user.id) 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() self.assertEqual(history['messages'][-1]['diagram']['kind'], 'plane') self.assertIsNone(history['messages'][0]['diagram']) @@ -351,3 +351,32 @@ class FollowUpHintTests(SimpleTestCase): last = build_messages('حالا دایره‌ای به شعاعِ ۲', None, 'نهم', history=history)[-1]['content'] self.assertIn(DIAGRAM_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('رتبه‌ی پنجمین نفر در مسابقه چند است؟')) diff --git a/rag/tests_tutor_quality.py b/rag/tests_tutor_quality.py index e307a88..d317f19 100644 --- a/rag/tests_tutor_quality.py +++ b/rag/tests_tutor_quality.py @@ -106,7 +106,9 @@ class TutorPromptTests(SimpleTestCase): self.assertEqual(messages[0]['role'], 'system') self.assertEqual(len(messages), 1 + HISTORY_TURNS + 1) 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']) def test_reasoning_models_get_no_temperature(self):