From 50df7bd371ae9a244590bbd2760f2bb23dc28933 Mon Sep 17 00:00:00 2001 From: Zareian Date: Tue, 29 Sep 2026 06:11:43 +0330 Subject: [PATCH] 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 --- CLAUDE.md | 10 +- rag/services/diagram.py | 252 +++++++++++++++++++++++++++++++++++-- rag/services/generation.py | 12 +- rag/tests_diagram.py | 74 ++++++++++- 4 files changed, 335 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a73c727..b1512f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -403,7 +403,15 @@ anyone who can read this repo can forge a token for any user.** `?gallery` فقط در حالتِ توسعه. کاتالوگ بخش‌بخش است و کنارِ هر سوال فقط بخشِ مربوط می‌رود (`hint_for`)؛ سوالِ ادامه‌ای بخشِ سوالِ قبلی را می‌گیرد (`follow_up_hint_for`)، و درخواستِ صریحِ «شکلش رو بکش» بی‌کلمه‌ی موضوعی کلِ کاتالوگ را (`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`); خانه خودِ مسیر است با «شروع». diff --git a/rag/services/diagram.py b/rag/services/diagram.py index c59b65d..86257ac 100644 --- a/rag/services/diagram.py +++ b/rag/services/diagram.py @@ -21,6 +21,7 @@ متناهی و محدود، متنِ کوتاه. تکه‌ی نامعتبر بی‌صدا حذف می‌شود؛ متنِ پاسخ هیچ‌وقت به‌خاطرِ شکل از دست نمی‌رود. آزموده نشده: رفتارِ مدل‌های دیگر جز gpt-4.1-mini. """ +import ast import json import logging import math @@ -46,9 +47,12 @@ _HEAD = ( 'قالبِ بلوکِ : فقط یک JSON، کلیدها انگلیسی، برچسب‌ها فارسی یا ریاضی. هر شکل "kind" و ' '"title"ِ کوتاهِ فارسی دارد. مختصاتی را که قابلِ حساب است خودت حساب نکن؛ بگو «چه»، اپ «کجا» را ' 'دقیق حساب می‌کند. رنگ فقط: blue red green orange purple teal pink gray black yellow. ' - 'در متنِ پاسخ به شکل اشاره کن («در شکلِ پایین…») با همان نام‌ها. یک شکلِ روشن بهتر از شکلِ شلوغ.' + 'در متنِ پاسخ به شکل اشاره کن («در شکلِ پایین…») با همان نام‌ها. یک شکلِ روشن بهتر از شکلِ شلوغ. ' + 'شکلی که به فهمیدن کمک نمی‌کند (تبدیلِ واحد، ضرب یا تقسیمِ ساده، مدتِ زمان) نگذار.' ) _PLANE = ( + 'مثلث و چهارضلعی (مستطیل، مربع، متوازی‌الاضلاع، لوزی، ذوزنقه) را همیشه با triangle و quad بکش و مختصاتِ رأس‌هایشان را ' + 'هرگز خودت ننویس؛ polygon فقط وقتی مسئله خودش مختصاتِ رأس‌ها را داده است.\n' 'plane (هندسه و مختصات): {"kind":"plane","title":"…","axes":false,"grid":false,' '"points":{"A":[0,0],"B":[4,0],"P":{"polar":[1,30]},"Q":{"polar":[2,60],"from":"A"},"M":{"mid":["A","B"]},' '"H":{"foot":["C","A","B"]},"I":{"intersect":[["A","B"],["C","D"]]},"T":{"along":["A","B",0.25]}},"items":[…]}\n' @@ -64,10 +68,20 @@ _PLANE = ( '{"type":"arc","center":"O","r":2,"start":0,"end":60} · {"type":"sector",…} · ' '{"type":"angle","vertex":"B","from":"A","to":"C","label":"۴۰°","arcs":1} (زاویه‌ی داخلی؛ arcs ۱ تا ۳ برای زاویه‌های مساوی) · ' '{"type":"rightAngle","vertex":"H","from":"A","to":"B"} · {"type":"polygon","points":["A","B","C"]} · ' + '{"type":"triangle","names":["A","B","C"],"sides":{"AB":6,"AC":8},"angles":{"A":90},"labels":{"BC":"x"},"altitude":"C"} ' + '(مثلث از روی داده‌های مسئله: هر ضلع و زاویه‌ای که مسئله داده، به همان عدد؛ اپ رأس‌ها را با قانونِ سینوس و کسینوس ' + 'می‌سازد؛ labels برای ضلع یا زاویه‌ی مجهول («x»، «?»)؛ altitude ارتفاع از آن رأس با پای H، labels.h برچسبش) · ' + '{"type":"quad","shape":"rectangle","names":["A","B","C","D"],"w":8,"h":5} (چهارضلعی: rectangle با w و h؛ square با ' + 'side؛ parallelogram با w و side یا h و angle؛ rhombus با d1 و d2 (یا side و angle)؛ trapezoid با w قاعده‌ی پایین، top ' + 'قاعده‌ی بالا، h ارتفاع، "right":true برای قائم؛ labels مثلِ مثلث). نام‌ها: مستطیل rectangle، مربع square، ' + 'متوازی‌الاضلاع parallelogram، لوزی rhombus، ذوزنقه trapezoid. جای شکل: اگر دو رأسِ اولش از قبل مختصات دارند، روی همان ضلع ' + 'ساخته می‌شود؛ چند شکلِ جدا خودکار کنارِ هم می‌نشینند؛ "at":[x,y] جای رأسِ اول. بردار یا انتقال: ' + '"B":{"translate":[2,3],"from":"A"} · ' '{"type":"regularPolygon","n":5,"r":2,"names":["A","B","C","D","E"]} (چندضلعیِ منتظم؛ رأس‌ها را اپ می‌سازد و ' 'با همین نام‌ها قابلِ ارجاع‌اند؛ مختصاتِ رأسِ چندضلعیِ منتظم را هرگز خودت ننویس) · ' '{"type":"exteriorAngle","vertex":"B","from":"A","to":"C","label":"72°"} (زاویه‌ی خارجیِ رأسِ B: اپ ضلعِ AB را از B ' 'امتداد می‌دهد و زاویه‌ی بینِ امتداد و BC را می‌کشد) · ' + 'برای دوران، تقارن، انتقال و تجانس همیشه هم شکلِ اصلی و هم تصویرش را بکش (با image؛ مختصاتِ تصویر را خودت ننویس) · ' '{"type":"image","of":["A","B","C"],"transform":{"rotate":{"center":"C","deg":-90}}} (نامِ تصویرها A′؛ ' 'rotate مثبت=پادساعتگرد و «در جهتِ عقربه‌ها» منفی؛ یا {"reflect":"x"|"y"|"y=x"|["A","B"]}، {"translate":[2,1]}، ' '{"scale":{"center":"O","k":2}}) · {"type":"text","at":[1,2],"text":"…"}' @@ -98,9 +112,19 @@ _PARTS = ( '"compare":[{"parts":5,"shaded":1,"label":"یک‌پنجم"},{"parts":10,"shaded":9}] که نوارهای هم‌اندازه می‌کشد) · ' 'grid (درصد، شمردنِ خانه): {"kind":"grid","title":"…","rows":5,"cols":5,"shaded":5} · ' 'groups (مدلِ ضرب و تقسیم): {"kind":"groups","title":"…","groups":3,"each":4,"item":"circle"|"square"|"star"}. ' + 'کسری از یک کل («نیمی از زمین»، «یک‌سوم از کیک») با fraction یا grid است، نه groups. ' 'کسرِ جبری (با x یا a) و ساده کردنِ عبارت شکل ندارد.' ) -_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 = ( 'bar | linechart | pie: {"kind":"bar","title":"…","labels":["سیب","پرتقال"],"values":[5,8],"unit":"نفر"} ' '(درصدِ دایره‌ای را اپ حساب می‌کند)' @@ -111,13 +135,15 @@ _TABLE = ( ) _SETS = ( '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]],' '"leftLabel":"دامنه","rightLabel":"برد"}' ) _TREE = ( 'tree (احتمال، شمارش): {"kind":"tree","title":"…","root":{"label":"شروع","children":[{"label":"رو","p":"1/2",' - '"children":[…]},…]}} (حداکثر سه سطح)' + '"children":[…]},…]}} (حداکثر سه سطح) · چرخنده‌ی رنگی (فرفره): {"kind":"pie","title":"…","labels":["قرمز","آبی"],' + '"values":[3,1]} با سهمِ هر رنگ' ) _SOLID = ( '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'ذوزنقه', 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*راست', r'شیب', r'مجانب', r'مشتق', r'اکسترمم', r'ماکزیمم', r'مینیمم', r'صعودی', r'نزولی', r'پیوسته', r'حد\b', r'\blim', r'نامعادله', r'مماس', r'log', r'f\(x\)', r'y\s*=') @@ -142,11 +168,12 @@ SECTIONS = [ # «یک‌پنجم»، «نُه‌دهم»، «سه‌چهارم»: کسر با کلمه، که کتابِ دبستان همین‌طور می‌نویسد 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'تعیین\s*علامت')), _TABLE), (_W((r'مجموعه', r'\bون\b', r'اشتراک', r'اجتماع', r'پیکانی', r'دامنه', r'برد\b', r'زوج\s*مرتب')), _SETS), (_W((r'احتمال', r'سکه', r'تاس', r'درختی')), _TREE), + (_W((r'الگو', r'چوب\s*کبریت', r'دنباله', r'شکل[\u0650\s]*(?:بعدی|شماره|[0-9۰-۹])')), _PATTERN), (_W((r'مکعب', r'منشور', r'هرم', r'استوانه', r'مخروط', r'کره', r'حجم', r'نیم‌کره')), _SOLID), ] DIAGRAM_SPEC = '\n'.join([_HEAD] + [text for _, text in SECTIONS]) @@ -194,6 +221,21 @@ def asked_hint_for(question: str) -> str: return '\n'.join([ASKED_HINT, DIAGRAM_SPEC]) +_CLOCK_TRIGGER = next(pattern for pattern, section in SECTIONS if section is _CLOCK) + + +def fits(question: str, spec: dict | None) -> dict | None: + """ + نگهبانِ معنا پس از وارسیِ قالب: ساعتِ دیواری فقط وقتی سوال واقعاً درباره‌ی خواندنِ ساعت است. + سنجشِ ۲۰۲۶-۰۹-۲۹: مدل برای «۲۰ درصدِ یک ساعت» و «عقربه‌ی فرفره» ساعت کشید؛ این دو خطا + گمراه‌کننده‌اند (بچه فکر می‌کند جواب «۱۲:۱۲» است) و بی‌شکل بودن بهتر است. + """ + if spec and spec.get('kind') == 'clock' and not _CLOCK_TRIGGER.search(question or ''): + logger.info('diagram: ساعت برای سوالِ بی‌ساعت، دور ریخته شد') + return None + return spec + + def hint_for(question: str) -> str: """یادآوری و فقط آن بخش‌هایی از کاتالوگ که به همین سوال می‌خورند؛ سوالِ بی‌شکل → ''.""" text = question or '' @@ -309,6 +351,8 @@ def _point_def(value): if not (isinstance(a, (list, tuple)) and len(a) == 3): raise ValueError('along') return {'along': [_ref(a[0]), _ref(a[1]), _num(a[2], -10, 10)]} + if 'translate' in value and value.get('from') is not None: + return {'translate': _xy(value['translate']), 'from': _ref(value['from'])} if 'fx' in value: e, x = value['fx'] return {'fx': [_expr(e), _num(x)]} @@ -353,6 +397,49 @@ def _range(item, lo_key='from', hi_key='to', lo=-5.0, hi=5.0): return a, b +_WRAPPED_ITEMS = {'triangle', 'quad', 'regularPolygon', 'polygon', 'circle', 'segment', 'angle', 'exteriorAngle'} +_LAYOUTS = {'row', 'square', 'rect', 'triangle', 'stair', 'L'} +_QUADS = {'rectangle', 'square', 'parallelogram', 'rhombus', 'trapezoid'} + + +def _quad_aliases(item: dict) -> dict: + """نام‌های دیگری که مدل برای اندازه‌ها نوشت: bases، height، width، length، base.""" + item = dict(item) + bases = item.pop('bases', None) + if isinstance(bases, list) and bases: + item.setdefault('w', bases[0]) + if len(bases) > 1: + item.setdefault('top', bases[1]) + rect = item.get('shape') in ('rectangle', None) + for alias, key in (('height', 'h'), ('base', 'w'), ('length', 'w'), ('width', 'h' if rect else 'w')): + if alias in item: + item.setdefault(key, item.pop(alias)) + return item + + +def _names(value, n: int) -> list[str]: + if not (isinstance(value, list) and len(value) == n and all(isinstance(v, str) and _NAME_OK.match(v) for v in value) + and len(set(value)) == n): + raise ValueError('names') + return value + + +def _side(key, names) -> bool: + """«AB» یا «BA»: دو رأسِ متفاوت از همین شکل.""" + return isinstance(key, str) and any(key == a + b for a in names for b in names if a != b) + + +def _shape_labels(item: dict, out: dict, names: list[str], extra=('h',)): + labels = {} + for key, value in list((item.get('labels') or {}).items())[:8]: + if key in names or key in extra or _side(key, names): + text = _label(value, 12) + if text: + labels[key] = text + if labels: + out['labels'] = labels + + def _plane_item(item: dict) -> dict: kind = item.get('type') out: dict = {'type': kind} @@ -467,6 +554,70 @@ def _plane_item(item: dict) -> dict: names = item.get('names') if isinstance(names, list): out['names'] = [_label(n, 6) for n in names[:12]] + elif kind == 'triangle': + names = _names(item.get('names') or ['A', 'B', 'C'], 3) + out['names'] = names + sides, symbolic = {}, {} + for key, value in list((item.get('sides') or {}).items())[:3]: + if _side(key, names): + try: + sides[key] = _num(value, 0.001, MAX_COORD) + except Invalid: + if isinstance(value, str) and value.strip(): + symbolic[key] = value + if symbolic: + item = {**item, 'labels': {**symbolic, **(item.get('labels') or {})}} + angles = {} + for key, value in list((item.get('angles') or {}).items())[:3]: + if key in names: + angles[key] = _num(value, 0.1, 179.9) + if sum(angles.values()) >= 180: + raise ValueError('angles') + if sides: + out['sides'] = sides + if angles: + out['angles'] = angles + _shape_labels(item, out, names) + if item.get('altitude') in names: + out['altitude'] = item['altitude'] + if item.get('at') is not None: + out['at'] = _xy(item['at']) + if isinstance(item.get('fill'), bool): + out['fill'] = item['fill'] + elif kind == 'quad': + item = _quad_aliases(item) + shape = item.get('shape') + if shape is None: + shape = 'square' if item.get('side') is not None and item.get('w') is None else 'rectangle' + if shape not in _QUADS: + raise ValueError('quad shape') + out['shape'] = shape + names = _names(item.get('names') or ['A', 'B', 'C', 'D'], 4) + out['names'] = names + symbolic = {} + for key in ('w', 'h', 'side', 'top', 'd1', 'd2'): + if item.get(key) is None: + continue + try: + out[key] = _num(item[key], 0.001, MAX_COORD) + except Invalid: + # «"side":"a"»: اندازه‌ی نمادین؛ شکل با پیش‌فرض کشیده می‌شود و «a» برچسب می‌شود + if isinstance(item[key], str) and item[key].strip(): + symbolic[key] = item[key] + if item.get('angle') is not None: + out['angle'] = _num(item['angle'], 1, 179) + if isinstance(item.get('right'), bool): + out['right'] = item['right'] + if item.get('at') is not None: + out['at'] = _xy(item['at']) + if symbolic: + edge = {'w': names[0] + names[1], 'side': names[0] + names[1], 'top': names[3] + names[2], + 'h': 'h' if shape in ('parallelogram', 'trapezoid', 'rhombus') else names[1] + names[2], + 'd1': 'd1', 'd2': 'd2'} + item = {**item, 'labels': {**{edge[k]: v for k, v in symbolic.items()}, **(item.get('labels') or {})}} + _shape_labels(item, out, names, extra=('h', 'd1', 'd2')) + if isinstance(item.get('fill'), bool): + out['fill'] = item['fill'] elif kind == 'text': out.update(at=_xy(item['at']), text=_label(item.get('text'))) if not out['text']: @@ -697,6 +848,19 @@ def validate(spec) -> dict | None: if isinstance(names, list) and len(names) == len(sets): out['names'] = [_label(n, 12) for n in names] return out + if kind == 'pattern': + stages = [_clamp(v, 0, 12) for v in (spec.get('stages') or [])[:5]] + if not stages: + return None + layout = spec.get('layout') if spec.get('layout') in _LAYOUTS else 'row' + unit = spec.get('unit') if spec.get('unit') in ('square', 'dot', 'stick') else 'square' + out = {'kind': 'pattern', 'title': title, 'layout': layout, 'unit': unit, 'stages': stages} + for key in ('next', 'counts'): + if isinstance(spec.get(key), bool): + out[key] = spec[key] + if spec.get('color') in COLORS: + out['color'] = spec['color'] + return out if kind == 'mapping': left, right = _strings(spec.get('left'), 10, 10), _strings(spec.get('right'), 10, 10) arrows = [] @@ -771,6 +935,59 @@ def _repair(body: str) -> str: 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): """ شکل‌هایی که مدل با کمی فاصله از قالب نوشت ولی منظورش روشن است: @@ -781,6 +998,27 @@ def _normalize(obj): if not isinstance(obj, dict): return obj kind = obj.get('kind') + if kind is None and isinstance(obj.get('shapes'), list) and obj['shapes'] and isinstance(obj['shapes'][0], dict): + return _normalize(obj['shapes'][0]) + if kind == 'compare' and isinstance(obj.get('compare'), list): + return {**obj, 'kind': 'fraction'} + # «"kind":"trapezoid","bases":[10,6],"height":4» (چتِ واقعی، ۲۰۲۶-۰۹-۲۹): نامِ شکل به‌جای quad + if kind in _QUADS: + obj = {**obj, 'kind': 'quad', 'shape': kind} + kind = 'quad' + # «"kind":"quad"» یا «"kind":"triangle"» به‌جای item داخلِ plane (سنجشِ ۲۰۲۶-۰۹-۲۹: ۱۵ از ۴۰) + if kind in _WRAPPED_ITEMS: + item = {k: v for k, v in obj.items() if k not in ('kind', 'title', 'axes', 'grid', 'points', 'items')} + item['type'] = kind + extra = [i for i in (obj.get('items') or []) if isinstance(i, dict)] if isinstance(obj.get('items'), list) else [] + if kind in ('triangle', 'quad'): + # ضلع‌های تکراری که مدل کنارش کشید، روی ضلع‌های خودِ شکل می‌افتند و لازم نیستند + extra = [i for i in extra if i.get('type') != 'segment' or i.get('dashed')] + plane = {'kind': 'plane', 'title': obj.get('title', ''), 'points': obj.get('points') or {}, 'items': [item, *extra]} + for key in ('axes', 'grid'): + if key in obj: + plane[key] = obj[key] + return plane if kind is None and 'type' in obj: return {'kind': 'plane', 'title': obj.get('title', ''), 'points': {}, 'items': [obj]} if kind in ('function', 'piecewise') and 'expr' in obj or kind == 'function' and 'pieces' in obj: @@ -820,7 +1058,7 @@ def parse(body: str) -> dict | None: if body.startswith('```'): body = body.strip('`').removeprefix('json').strip() try: - return validate(_normalize(json.loads(_repair(body)))) + return validate(_normalize(_loads(body))) except (json.JSONDecodeError, TypeError, ValueError): logger.info('diagram: JSONِ نامعتبر از مدل، دور ریخته شد') return None diff --git a/rag/services/generation.py b/rag/services/generation.py index 14dcde4..6173025 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-v8-asked-figure' +PROMPT_VERSION = 'tutor-v10-shape-anchor' _PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'} _MIDDLE = {'هفتم', 'هشتم', 'نهم'} @@ -259,12 +259,16 @@ def build_messages(question: str, context_chunks: list[dict] | None, 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( question, contexts, focus, primary=grade_tier(grade_title) == 'primary', previous=previous)}) 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, grade_title: str = '', history: list[dict] | None = None, 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() # بلوکِ شکل پیش از to_plain جدا می‌شود؛ to_plain کسرها و «*» را در JSON عوض می‌کرد. 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') if not answer: answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.' @@ -351,7 +357,7 @@ def stream_answer(question: str, context_chunks: list[dict] | None, yield clean tail = sanitizer.feed(splitter.flush()) + sanitizer.flush() 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(): yield tail diff --git a/rag/tests_diagram.py b/rag/tests_diagram.py index 3e6b697..8923879 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-v8-asked-figure') + self.assertEqual(message.prompt_version, 'tutor-v10-shape-anchor') 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']) @@ -356,7 +356,7 @@ class FollowUpHintTests(SimpleTestCase): from .services.diagram import ASKED_HINT, DIAGRAM_SPEC, asked_hint_for 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(DIAGRAM_SPEC, last) for q in ('مشکل دارم با این سوال', 'می‌خوام یه سوال بپرسم', '۳۴۵ × ۱۲'): @@ -390,3 +390,73 @@ class ThirdRoundTests(SimpleTestCase): self.assertIn(_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))