draw a diagram in tutor answers: validated JSON from the model, rendered by the app; add the fourth companion tedi

The model ends a shape question with <diagram>{json}</diagram>; rag/services/diagram.py
splits it off before mathtext, validates it against a whitelist and stores it on
Message.diagram (ai_tutor.0006). It reaches the app in the SSE done event and in
conversation history. Prompt tutor-v6-diagrams; a hint next to the question (shape
words only) raised gpt-4.1-mini from 3/12 to 12/12 diagrams, 0/8 on arithmetic.

progress.0005 adds tedi (600 xp) to the companion catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 17:44:44 +03:30
parent 74c3ee5e86
commit 33d8f43d74
10 changed files with 581 additions and 9 deletions

303
rag/services/diagram.py Normal file
View File

@@ -0,0 +1,303 @@
"""
شکلِ پاسخِ دستیار: مدل یک توصیفِ JSONِ کوتاه در <diagram>…</diagram> می‌نویسد و
اپ خودش آن را به SVG تبدیل می‌کند.
چرا مدل خودش SVG/HTML نمی‌نویسد (سنجیده ۲۰۲۶-۰۹-۲۸ روی gpt-4.1-mini):
- **امنیت:** HTMLِ مدل (که متنِ کتاب و سوالِ کاربر را هم خوانده) اگر مستقیم در
صفحه بنشیند، راهِ تزریقِ اسکریپت است. JSONِ اعتبارسنجی‌شده فقط داده است.
- **درستیِ هندسه:** مدل در حسابِ مختصات (cos 30° و …) خطا می‌کند؛ اینجا فقط
می‌گوید «نقطه روی دایره با زاویه‌ی ۳۰°» و فرانت مختصات را حساب می‌کند.
- **هزینه و سرعت:** یک JSONِ ۱۰۰–۲۰۰ توکنی به‌جای SVGِ چندصدتوکنی.
- Structured Outputs (json_schema) را این مدل دارد ولی کلِ پاسخ را JSON
می‌کند و استریمِ متن را خراب می‌کند؛ برای همین فقط بلوکِ آخر JSON است.
این ماژول سه کار می‌کند: قاعده‌ی پرامپت (`DIAGRAM_RULE`)، جدا کردنِ بلوک از متن
(قبل از `mathtext`، که JSON را خراب می‌کرد)، و اعتبارسنجیِ سخت‌گیر. شکلِ
نامعتبر بی‌صدا دور ریخته می‌شود؛ متنِ پاسخ هیچ‌وقت به‌خاطرِ شکل از دست نمی‌رود.
آزموده نشده: رفتارِ مدل‌های دیگر (فقط gpt-4.1-mini سنجیده شد، ۱۸ سوال).
"""
import json
import logging
import math
import re
logger = logging.getLogger(__name__)
OPEN, CLOSE = '<diagram>', '</diagram>'
_BLOCK = re.compile(r'<diagram>(.*?)(?:</diagram>|$)', re.S)
DIAGRAM_RULE = (
'شکل (اپ این را به تصویر تبدیل می‌کند):\n'
'- وقتی سوال درباره‌ی یکی از این‌هاست، در پایانِ پاسخ دقیقاً یک بلوکِ '
'<diagram>…</diagram> بگذار: شکلِ هندسی (مثلث، چندضلعی، دایره، زاویه، خطِ موازی)، '
'دایره‌ی مثلثاتی، حرکت یا مقایسه روی محورِ اعداد، نقطه یا نمودار در صفحه‌ی مختصات، '
'کسر یا درصد به‌صورتِ تکه‌های یک شکل. هر سوالی که کلمه‌ی مثلث، دایره، زاویه، محور، '
'نمودار، کسر یا تکه دارد، شکل می‌خواهد. فقط برای حسابِ عددی یا ساده‌کردنِ عبارتِ '
'جبری که شکلی ندارد، شکل نگذار.\n'
'- داخلِ بلوک فقط یک JSON است، نه متنِ دیگر، نه SVG، نه HTML. مختصات را خودت حساب '
'نکن؛ نقطه‌ی روی دایره را با polar بده ([شعاع، زاویه به درجه]) تا اپ دقیق رسمش کند.\n'
'- در متنِ پاسخ به شکل اشاره کن («در شکلِ پایین…») و برچسب‌های شکل همان نام‌های متن باشند.\n'
'- سه نوع شکل:\n'
' ۱) صفحه‌ی مختصات یا هندسه:\n'
' {"kind":"plane","title":"…","axes":true,"points":{"O":[0,0],"A":[1,0],"P":{"polar":[1,30]}},'
'"items":[{"type":"circle","center":"O","r":1},{"type":"segment","from":"O","to":"P"},'
'{"type":"angle","vertex":"O","from":"A","to":"P","label":"30°"},'
'{"type":"rightAngle","vertex":"H","from":"A","to":"B"},{"type":"polygon","points":["A","B","C"]},'
'{"type":"line","through":["A","B"]},{"type":"arrow","from":"A","to":"B"},'
'{"type":"point","point":"V","label":"رأس (1, −1)"},'
'{"type":"function","expr":"x^2-2*x","from":-1,"to":3,"label":"y = x² − 2x"},'
'{"type":"text","at":[0.5,1.2],"text":"…"}]}\n'
' نقطه‌ها با نامشان برچسب می‌خورند (برچسبِ دیگر با item نوعِ point)؛ نقطه‌ی کمکی که '
'برچسب نمی‌خواهد با نامِ شروع‌شده با «_» بیاید. ضلع یا اندازه را با label روی segment '
'بنویس: {"type":"segment","from":"A","to":"B","label":"۳"}. در function فقط x، عدد، '
'+ − * / ^ ، پرانتز و sin cos tan sqrt abs log بنویس. "axes": false برای شکلِ هندسیِ بی‌محور '
'(مثلث، چندضلعی)؛ true فقط برای صفحه‌ی مختصات و نمودار. line خطِ بی‌پایان است؛ ضلع، قطر و شعاع '
'همیشه segment‌اند.\n'
' ۲) محورِ اعداد: {"kind":"numberline","title":"…","min":-5,"max":5,'
'"points":[{"at":2,"label":"شروع"},{"at":-1,"label":"پایان"}],'
'"jumps":[{"from":2,"to":-1,"label":"۳ واحد به چپ"}]}\n'
' ۳) کسر: {"kind":"fraction","title":"…","shape":"circle","parts":4,"shaded":3}\n'
'- مثال: «وترِ مثلثِ قائم‌الزاویه با ضلع‌های ۳ و ۴» → polygon با سه رأس، rightAngle روی رأسِ قائمه '
'و label روی هر ضلع (وتر با «?» یا جوابش).\n'
'- title کوتاه و فارسی. حداکثر ۱۲ نقطه و ۱۲ item.'
)
# کلمه‌هایی که یعنی «این سوال شکل دارد». سنجشِ ۲۰۲۶-۰۹-۲۸: قاعده‌ی تهِ پرامپتِ
# سیستم به‌تنهایی برای مثلث ۱ از ۴ و پنج‌ضلعی ۰ از ۴ شکل می‌گذاشت؛ همان درسِ
# «/» — قاعده کنارِ خودِ سوال اثر دارد، نه تهِ پرامپت.
_SHAPE_WORDS = re.compile(
r'مثلث|دایره|زاویه|ضلع|چندضلعی|مربع|مستطیل|لوزی|ذوزنقه|متوازی|شعاع|قطر|وتر|محیط|مساحت|'
r'محور|نمودار|مختصات|سهمی|خط\s*راست|شیب|نقطه|کسر|تکه|پیتزا|کیک|درصد|سینوس|کسینوس|تانژانت|'
r'sin|cos|tan|°|درجه'
)
DIAGRAM_HINT = (
'این سوال شکل دارد: در پایانِ پاسخ بلوکِ <diagram>…</diagram> را طبقِ قاعده‌ی «شکل» بگذار.'
)
def hint_for(question: str) -> str:
"""یادآوریِ کنارِ سوال، فقط برای سوال‌هایی که کلمه‌ی شکل‌دار دارند."""
return DIAGRAM_HINT if _SHAPE_WORDS.search(question or '') else ''
MAX_POINTS = 16
MAX_ITEMS = 16
MAX_LABEL = 40
MAX_COORD = 1000
_EXPR_OK = re.compile(r'^[0-9x+\-*/^().,\s]*(?:(?:sin|cos|tan|sqrt|abs|log|ln|pi)[0-9x+\-*/^().,\s]*)*$')
_NAME_OK = re.compile(r'^[\w′\']{1,6}$')
def _num(value, lo=-MAX_COORD, hi=MAX_COORD):
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError('not a number')
if not math.isfinite(value) or not lo <= value <= hi:
raise ValueError('out of range')
return round(float(value), 4)
def _label(value) -> str:
if value is None:
return ''
if isinstance(value, (int, float)) and not isinstance(value, bool):
value = str(value)
if not isinstance(value, str):
raise ValueError('label')
return value.strip()[:MAX_LABEL]
def _xy(value):
if not (isinstance(value, (list, tuple)) and len(value) == 2):
raise ValueError('xy')
return [_num(value[0]), _num(value[1])]
def _point_def(value):
if isinstance(value, dict) and 'polar' in value:
polar = value['polar']
if not (isinstance(polar, (list, tuple)) and len(polar) == 2):
raise ValueError('polar')
out = {'polar': [_num(polar[0], 0, MAX_COORD), _num(polar[1], -3600, 3600)]}
if 'from' in value:
out['from'] = _ref(value['from'])
return out
return _xy(value)
def _ref(value):
"""نامِ نقطه یا مختصاتِ مستقیم."""
if isinstance(value, str):
if not _NAME_OK.match(value):
raise ValueError('ref name')
return value
return _xy(value)
def _plane_item(item: dict) -> dict:
kind = item.get('type')
out = {'type': kind}
if kind == 'circle':
out.update(center=_ref(item['center']), r=_num(item['r'], 0, MAX_COORD))
elif kind in ('segment', 'arrow'):
out.update(**{'from': _ref(item['from'])}, to=_ref(item['to']))
elif kind == 'line':
a, b = item['through']
out['through'] = [_ref(a), _ref(b)]
elif kind in ('angle', 'rightAngle'):
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:
raise ValueError('polygon')
out['points'] = [_ref(p) for p in points]
elif kind == 'point':
out['point'] = _ref(item['point'])
elif kind == 'function':
expr = item['expr']
if not isinstance(expr, str) or len(expr) > 80 or not _EXPR_OK.match(expr.replace(' ', '')):
raise ValueError('expr')
out.update(expr=expr, **{'from': _num(item.get('from', -5))}, to=_num(item.get('to', 5)))
if out['from'] >= out['to']:
raise ValueError('function range')
elif kind == 'text':
out.update(at=_xy(item['at']), text=_label(item.get('text')))
if not out['text']:
raise ValueError('empty text')
else:
raise ValueError(f'unknown item {kind!r}')
if kind != 'text' and item.get('label') is not None:
out['label'] = _label(item['label'])
return out
def validate(spec) -> dict | None:
"""
نسخه‌ی تمیزِ spec، یا None. فقط کلیدها و نوع‌های شناخته‌شده رد می‌شوند؛
item یا نقطه‌ی خراب تنها حذف می‌شود، ولی شکلی که چیزی برای کشیدن ندارد None است.
"""
if not isinstance(spec, dict):
return None
kind = spec.get('kind', 'plane')
try:
title = _label(spec.get('title'))
except ValueError:
title = ''
try:
if kind == 'plane':
points = {}
raw_points = spec.get('points') or {}
if not isinstance(raw_points, dict):
return None
for name, value in list(raw_points.items())[:MAX_POINTS]:
if isinstance(name, str) and _NAME_OK.match(name):
try:
points[name] = _point_def(value)
except (ValueError, KeyError, TypeError):
continue
items = []
for item in (spec.get('items') or [])[:MAX_ITEMS]:
if isinstance(item, dict):
try:
clean = _plane_item(item)
except (ValueError, KeyError, TypeError):
continue
items.append(clean)
if not items and not points:
return None
# بی‌محور مگر مدل صریحاً بخواهد یا نمودارِ تابع باشد: مثلثی که محور
# گرفته بود عددهای محور را روی برچسبِ ضلع‌ها می‌انداخت (۲۰۲۶-۰۹-۲۸).
axes = spec.get('axes')
if not isinstance(axes, bool):
axes = any(i['type'] == 'function' for i in items)
return {'kind': 'plane', 'title': title, 'axes': axes,
'points': points, 'items': items}
if kind == 'numberline':
lo, hi = _num(spec.get('min', -5)), _num(spec.get('max', 5))
if not lo < hi or hi - lo > 200:
return None
marks = []
for p in (spec.get('points') or [])[:MAX_POINTS]:
try:
marks.append({'at': _num(p['at']), 'label': _label(p.get('label'))})
except (ValueError, KeyError, TypeError):
continue
jumps = []
for j in (spec.get('jumps') or [])[:MAX_ITEMS]:
try:
jumps.append({'from': _num(j['from']), 'to': _num(j['to']), 'label': _label(j.get('label'))})
except (ValueError, KeyError, TypeError):
continue
return {'kind': 'numberline', 'title': title, 'min': lo, 'max': hi,
'points': marks, 'jumps': jumps}
if kind == 'fraction':
parts = int(_num(spec.get('parts'), 1, 24))
shaded = int(_num(spec.get('shaded', 0), 0, parts))
shape = spec.get('shape') if spec.get('shape') in ('circle', 'rect') else 'circle'
return {'kind': 'fraction', 'title': title, 'shape': shape, 'parts': parts, 'shaded': shaded}
except (ValueError, KeyError, TypeError):
return None
return None
def extract(text: str) -> tuple[str, dict | None]:
"""(متنِ بی‌بلوک، spec یا None). بلوکِ بسته‌نشده هم جدا می‌شود تا JSON در متن نماند."""
if OPEN not in (text or ''):
return text, None
match = _BLOCK.search(text)
spec = parse(match.group(1)) if match else None
return _BLOCK.sub('', text).rstrip(), spec
def parse(body: str) -> dict | None:
body = (body or '').strip()
if body.startswith('```'):
body = body.strip('`').removeprefix('json').strip()
try:
return validate(json.loads(body))
except (json.JSONDecodeError, TypeError):
logger.info('diagram: JSONِ نامعتبر از مدل، دور ریخته شد')
return None
class StreamSplitter:
"""
متنِ استریم را عبور می‌دهد و بلوکِ <diagram> را کنار می‌گذارد. تهِ هر تکه که
ممکن است آغازِ «<diagram>» باشد نگه داشته می‌شود، چون برچسب می‌تواند بینِ
دو تکه‌ی استریم نصف شود.
"""
def __init__(self):
self._pending = ''
self._block: list[str] | None = None
self.diagram: dict | None = None
def feed(self, delta: str) -> str:
if self._block is not None:
self._block.append(delta)
joined = ''.join(self._block)
if CLOSE not in joined:
return ''
body, rest = joined.split(CLOSE, 1)
self.diagram = self.diagram or parse(body)
self._block = None
return self.feed(rest) if rest else ''
text = self._pending + delta
self._pending = ''
at = text.find(OPEN)
if at >= 0:
self._block = []
return text[:at] + self.feed(text[at + len(OPEN):])
for keep in range(min(len(OPEN) - 1, len(text)), 0, -1):
if OPEN.startswith(text[-keep:]):
self._pending = text[-keep:]
return text[:-keep]
return text
def flush(self) -> str:
if self._block is not None:
self.diagram = self.diagram or parse(''.join(self._block))
self._block = None
return ''
tail, self._pending = self._pending, ''
return tail

View File

@@ -25,6 +25,7 @@ from collections.abc import Iterator
from django.conf import settings
from . import clients
from . import diagram as diagram_mod
from .mathtext import StreamSanitizer, to_plain
logger = logging.getLogger(__name__)
@@ -37,7 +38,7 @@ _RAW_MARKUP = re.compile(r'\\[A-Za-z(\[]|\$|\*\*|^\s*#{1,6}\s|^\s*\|\s*:?-{3,}',
# بشود گفت «کدام نسخه‌ی پرامپت جوابِ بهتری می‌داد» — بدونِ این، همه‌ی فیدبک‌ها
# در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست. کشِ پاسخ هم با همین نسخه
# کلید می‌خورد، پس جوابِ نسخه‌ی قبلی بعد از دیپلوی دوباره سرو نمی‌شود.
PROMPT_VERSION = 'tutor-v5-slash-in-question'
PROMPT_VERSION = 'tutor-v6-diagrams'
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
_MIDDLE = {'هفتم', 'هشتم', 'نهم'}
@@ -158,6 +159,7 @@ def system_prompt(grade_title: str = '') -> str:
f'تو «پی» هستی، دستیارِ ریاضیِ اپِ آموزشیِ تدریسینو، و مثلِ یک {teacher} '
f'با یک {student} حرف می‌زنی. همیشه فارسی جواب بده.\n\n'
f'{_TONE[grade_tier(grade_title)]}\n\n{_METHOD}\n\n{_FORMAT.format(numbers=numbers)}'
f'\n\n{diagram_mod.DIAGRAM_RULE}'
)
@@ -176,13 +178,16 @@ _SLASH_READING = (
def _user_prompt(question: str, contexts: list[str], focus: str = '',
primary: bool = False) -> str:
asked = f'سوالِ دانش‌آموز: {question}'
shape_hint = diagram_mod.hint_for(f'{question}\n{focus}')
if shape_hint:
asked = f'{asked}\n\n{shape_hint}'
slash_rule = primary and '/' in question
if slash_rule:
asked = f'{_SLASH_READING}\n\n{asked}'
if focus:
asked = f'{focus}\n\n{asked}'
if not contexts:
return asked if (focus or slash_rule) else question
return asked if (focus or slash_rule or shape_hint) else question
# این جمله برای کتاب‌های دبیرستان نوشته شده بود (۳/۱۰۴ = 3.104) ولی به همه
# می‌رفت، و gpt-4.1-mini با آن هر کسرِ دبستانی را ممیز می‌خواند.
slash_note = (
@@ -273,6 +278,8 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
latency_ms = int((time.monotonic() - started) * 1000)
raw = (completion.choices[0].message.content or '').strip()
# بلوکِ شکل پیش از to_plain جدا می‌شود؛ to_plain کسرها و «*» را در JSON عوض می‌کرد.
raw, diagram = diagram_mod.extract(raw)
answer = to_plain(raw, fraction_word=grade_tier(grade_title) == 'primary')
if not answer:
answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.'
@@ -296,14 +303,18 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
# GapGPT هزینه‌ی دقیقِ هر فراخوانی را (به دلار) در usage می‌فرستد؛
# providerی که نفرستد None می‌دهد.
'costUsd': getattr(usage, 'cost', None),
'diagram': diagram,
}
return answer, list(_DEFAULT_FOLLOW_UPS), meta
def stream_answer(question: str, context_chunks: list[dict] | None,
grade_title: str = '', history: list[dict] | None = None,
focus: str = '') -> Iterator[str]:
focus: str = '', result: dict | None = None) -> Iterator[str]:
"""
`result` (اختیاری) بعد از پایانِ استریم کلیدِ 'diagram' را می‌گیرد: شکلِ
اعتبارسنجی‌شده یا None. بلوکِ شکل هیچ‌وقت به‌صورتِ متن yield نمی‌شود.
همون generate_answer ولی تکه‌به‌تکه (برای SSE). فقط متنِ پاسخ را yield
می‌کند؛ follow_upها ثابت‌اند و caller خودش اضافه‌شان می‌کند.
@@ -319,16 +330,19 @@ def stream_answer(question: str, context_chunks: list[dict] | None,
**completion_kwargs(model),
)
sanitizer = StreamSanitizer(fraction_word=grade_tier(grade_title) == 'primary')
splitter = diagram_mod.StreamSplitter()
for event in stream:
if not event.choices:
continue
delta = event.choices[0].delta.content or ''
if delta:
clean = sanitizer.feed(delta)
clean = sanitizer.feed(splitter.feed(delta))
if clean:
yield clean
tail = sanitizer.flush()
if tail:
tail = sanitizer.feed(splitter.flush()) + sanitizer.flush()
if result is not None:
result['diagram'] = splitter.diagram
if tail.strip():
yield tail