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:
2026-09-29 06:11:43 +03:30
parent a8cd681d65
commit 50df7bd371
4 changed files with 335 additions and 13 deletions

View File

@@ -21,6 +21,7 @@
متناهی و محدود، متنِ کوتاه. تکه‌ی نامعتبر بی‌صدا حذف می‌شود؛ متنِ پاسخ هیچ‌وقت
به‌خاطرِ شکل از دست نمی‌رود. آزموده نشده: رفتارِ مدل‌های دیگر جز gpt-4.1-mini.
"""
import ast
import json
import logging
import math
@@ -46,9 +47,12 @@ _HEAD = (
'قالبِ بلوکِ <diagram>: فقط یک 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

View File

@@ -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

View File

@@ -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))