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:
303
rag/services/diagram.py
Normal file
303
rag/services/diagram.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
171
rag/tests_diagram.py
Normal file
171
rag/tests_diagram.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""شکلِ پاسخِ دستیار: جدا کردن از متن، اعتبارسنجی، و رسیدنش تا SSE و تاریخچه."""
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
|
||||
from .services.clients import reset_clients_cache
|
||||
from .services.diagram import StreamSplitter, extract, validate
|
||||
from .tests import RAG_ON, _parse_sse
|
||||
|
||||
CIRCLE = {
|
||||
'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°'},
|
||||
],
|
||||
}
|
||||
ANSWER = 'در شکلِ پایین نقطهی P را میبینی.\n✅ جواب: 1/2\n<diagram>\n' + json.dumps(CIRCLE, ensure_ascii=False) + '\n</diagram>'
|
||||
|
||||
|
||||
class ValidateTests(SimpleTestCase):
|
||||
def test_real_model_output_passes_unchanged_in_meaning(self):
|
||||
spec = validate(CIRCLE)
|
||||
self.assertEqual(spec['points']['P'], {'polar': [1.0, 30.0]})
|
||||
self.assertEqual(len(spec['items']), 3)
|
||||
|
||||
def test_unknown_item_type_is_dropped_not_the_whole_diagram(self):
|
||||
spec = validate({**CIRCLE, 'items': CIRCLE['items'] + [{'type': 'html', 'html': '<script>x</script>'}]})
|
||||
self.assertEqual([i['type'] for i in spec['items']], ['circle', 'segment', 'angle'])
|
||||
|
||||
def test_function_expression_only_allows_math(self):
|
||||
ok = validate({'kind': 'plane', 'items': [{'type': 'function', 'expr': 'sin(x)+x^2', 'from': -3, 'to': 3}]})
|
||||
self.assertEqual(ok['items'][0]['expr'], 'sin(x)+x^2')
|
||||
for bad in ('alert(1)', 'x; fetch()', 'constructor', '__proto__'):
|
||||
self.assertIsNone(validate({'kind': 'plane', 'items': [{'type': 'function', 'expr': bad}]}), bad)
|
||||
|
||||
def test_numbers_must_be_finite_and_bounded(self):
|
||||
self.assertIsNone(validate({'kind': 'numberline', 'min': 0, 'max': 1e9}))
|
||||
spec = validate({'kind': 'plane', 'points': {'A': [float('inf'), 0], 'B': [1, 1]}, 'items': []})
|
||||
self.assertEqual(list(spec['points']), ['B'])
|
||||
|
||||
def test_labels_are_truncated_and_fraction_shaded_is_capped(self):
|
||||
spec = validate({'kind': 'fraction', 'parts': 4, 'shaded': 9, 'title': 'x' * 500})
|
||||
self.assertIsNone(spec)
|
||||
spec = validate({'kind': 'fraction', 'parts': 4, 'shaded': 3, 'title': 'x' * 500})
|
||||
self.assertEqual((spec['parts'], spec['shaded'], len(spec['title'])), (4, 3, 40))
|
||||
|
||||
def test_garbage_is_none(self):
|
||||
for bad in (None, [], 'svg', {'kind': 'svg'}, {'kind': 'plane', 'items': 'x'}):
|
||||
self.assertIsNone(validate(bad), bad)
|
||||
|
||||
|
||||
class ExtractTests(SimpleTestCase):
|
||||
def test_block_is_removed_from_text(self):
|
||||
text, spec = extract(ANSWER)
|
||||
self.assertNotIn('diagram', text)
|
||||
self.assertNotIn('{', text)
|
||||
self.assertTrue(text.endswith('✅ جواب: 1/2'))
|
||||
self.assertEqual(spec['kind'], 'plane')
|
||||
|
||||
def test_broken_json_drops_only_the_diagram(self):
|
||||
text, spec = extract('جواب ۵ است.\n<diagram>{"kind": "plane", </diagram>')
|
||||
self.assertEqual((text, spec), ('جواب ۵ است.', None))
|
||||
|
||||
def test_unclosed_block_never_leaks_json(self):
|
||||
text, _ = extract('جواب.\n<diagram>{"kind":"fraction","parts":4,"shaded":3}')
|
||||
self.assertEqual(text, 'جواب.')
|
||||
|
||||
def test_splitter_matches_extract_however_the_stream_is_cut(self):
|
||||
expected_text, expected_spec = extract(ANSWER)
|
||||
for step in (1, 2, 3, 5, 9, 40, 1000):
|
||||
splitter = StreamSplitter()
|
||||
out = ''.join(splitter.feed(ANSWER[i:i + step]) for i in range(0, len(ANSWER), step))
|
||||
out += splitter.flush()
|
||||
self.assertEqual(out.rstrip(), expected_text, f'step={step}')
|
||||
self.assertEqual(splitter.diagram, expected_spec, f'step={step}')
|
||||
|
||||
def test_splitter_passes_text_after_the_block(self):
|
||||
splitter = StreamSplitter()
|
||||
out = splitter.feed('الف <diagram>{"kind":"fraction","parts":2,"shaded":1}</diagram> ب') + splitter.flush()
|
||||
self.assertEqual(out, 'الف ب')
|
||||
self.assertEqual(splitter.diagram['parts'], 2)
|
||||
|
||||
def test_lone_less_than_sign_is_not_swallowed(self):
|
||||
splitter = StreamSplitter()
|
||||
out = splitter.feed('x <') + splitter.feed(' 3') + splitter.flush()
|
||||
self.assertEqual(out, 'x < 3')
|
||||
|
||||
|
||||
def _fake_stream(text: str, step: int = 7):
|
||||
events = [
|
||||
SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=text[i:i + step]))])
|
||||
for i in range(0, len(text), step)
|
||||
]
|
||||
create = lambda **kwargs: iter(events) # noqa: E731
|
||||
return SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
|
||||
|
||||
|
||||
@override_settings(**RAG_ON)
|
||||
class DiagramThroughTheApiTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
reset_clients_cache()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='dev-diagram', is_guest=True, name='')
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||||
|
||||
def test_stream_sends_text_without_json_and_diagram_in_done_and_history(self):
|
||||
from ai_tutor.models import Message
|
||||
|
||||
with patch('rag.services.retrieval.retrieve_context', return_value=([], {})), \
|
||||
patch('rag.services.clients.get_openai_client', return_value=_fake_stream(ANSWER)):
|
||||
resp = self.client.post('/api/ai/ask', {'userQuestion': 'سینوسِ ۳۰؟', 'stream': True}, format='json')
|
||||
events = _parse_sse(resp.streaming_content) # استریم همینجا اجرا میشود، داخلِ patch
|
||||
text = ''.join(json.loads(d)['delta'] for e, d in events if e == 'chunk')
|
||||
self.assertNotIn('<diagram', text)
|
||||
self.assertNotIn('"kind"', text)
|
||||
self.assertEqual(events[-1][0], 'done', events[-1])
|
||||
done = json.loads(events[-1][1])
|
||||
self.assertEqual(done['diagram']['points']['P'], {'polar': [1.0, 30.0]})
|
||||
|
||||
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')
|
||||
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'])
|
||||
|
||||
def test_answer_without_diagram_has_null(self):
|
||||
with patch('rag.services.retrieval.retrieve_context', return_value=([], {})), \
|
||||
patch('rag.services.clients.get_openai_client', return_value=_fake_stream('۲۳ × ۴۵ = ۱۰۳۵')):
|
||||
resp = self.client.post('/api/ai/ask', {'userQuestion': '۲۳×۴۵', 'stream': True}, format='json')
|
||||
done = json.loads(_parse_sse(resp.streaming_content)[-1][1])
|
||||
self.assertIsNone(done['diagram'])
|
||||
|
||||
|
||||
class ShapeHintTests(SimpleTestCase):
|
||||
def test_hint_goes_next_to_shape_questions_only(self):
|
||||
from .services.diagram import DIAGRAM_HINT, hint_for
|
||||
from .services.generation import _user_prompt
|
||||
|
||||
for q in ('وترِ مثلث با ضلعهای ۳ و ۴؟', 'سهچهارم پیتزا', 'نمودار y=x²', 'sin 30°'):
|
||||
self.assertEqual(hint_for(q), DIAGRAM_HINT, q)
|
||||
self.assertIn(DIAGRAM_HINT, _user_prompt(q, []))
|
||||
for q in ('۲۳ × ۴۵ چند میشود؟', 'عبارت (x+2)(x-3) را ساده کن.'):
|
||||
self.assertEqual(hint_for(q), '', q)
|
||||
self.assertEqual(_user_prompt(q, []), q)
|
||||
|
||||
def test_every_system_prompt_carries_the_diagram_rule(self):
|
||||
from .services.generation import system_prompt
|
||||
|
||||
for grade in ('سوم', 'هشتم', 'دوازدهم', ''):
|
||||
self.assertIn('<diagram>', system_prompt(grade))
|
||||
|
||||
|
||||
class AxesDefaultTests(SimpleTestCase):
|
||||
def test_geometry_without_axes_key_gets_no_axes_but_graphs_do(self):
|
||||
tri = validate({'kind': 'plane', 'points': {'A': [0, 0], 'B': [3, 0], 'C': [0, 4]},
|
||||
'items': [{'type': 'polygon', 'points': ['A', 'B', 'C']}]})
|
||||
self.assertFalse(tri['axes'])
|
||||
graph = validate({'kind': 'plane', 'items': [{'type': 'function', 'expr': 'x^2', 'from': -2, 'to': 2}]})
|
||||
self.assertTrue(graph['axes'])
|
||||
self.assertTrue(validate({**CIRCLE})['axes']) # صریحاً true خواسته شده
|
||||
Reference in New Issue
Block a user