Files
MathApp/rag/tests_diagram.py
Zareian 0917ab487f diagrams for every grade, sectioned prompt, repair of real model slips; fill lesson texts and thin islands
Diagrams: 14 kinds drawn by the app (plane with constructed points, graphs with
computed roots/extrema/tangents, number lines with intervals, solids, charts,
tables, Venn, mapping, trees, fractions, grids, groups, clock). The catalog is
split by topic and only the matching sections go next to a question (200-1073
tokens instead of 1854). parse() repairs slips seen on 96 real book problems:
Persian digits in JSON numbers, 2*pi, // comments, bare items, Venn with
Persian set names. Measured: 45/48 valid on the second round, 0/8 on plain
arithmetic; visual review of 83: 64 right, 3 weak, 16 wrong.

Content: 82 new questions so no island has fewer than four; fill_lesson_texts
writes Unit.blurb (58) and Lesson.example/steps (590) from verified book
problems, never the lesson's own quiz problem. seed_content only fills empty
fields, so admin edits survive a deploy. Tedi unlocks at 500 xp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:22:13 +03:30

323 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""شکلِ پاسخِ دستیار: جدا کردن از متن، اعتبارسنجی، و رسیدنش تا 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.assertEqual(spec['shaded'], 4)
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.assertTrue(hint_for(q).startswith(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 خواسته شده
class SectionedSpecTests(SimpleTestCase):
"""کنارِ هر سوال فقط بخشِ مربوط به همان سوال از کاتالوگ می‌رود (هزینه‌ی توکن)."""
def test_only_matching_sections_are_sent(self):
from .services.diagram import _GRAPH, _PLANE, _SOLID, _TREE, hint_for
solid = hint_for('حجمِ استوانه‌ای با شعاعِ ۳ و ارتفاعِ ۵ چقدر است؟')
self.assertIn(_SOLID, solid)
self.assertNotIn(_GRAPH, solid)
self.assertNotIn(_TREE, solid)
graph = hint_for('نمودارِ تابعِ y = x² − 4 را رسم کن')
self.assertIn(_GRAPH, graph)
self.assertIn(_PLANE, graph) # نمودار داخلِ plane است
self.assertNotIn(_SOLID, graph)
self.assertLess(len(solid), len(graph))
def test_bar_chart_does_not_pull_function_graphs(self):
from .services.diagram import _CHARTS, _GRAPH, hint_for
text = hint_for('نمودارِ ستونیِ آب‌میوه‌ها را رسم کن')
self.assertIn(_CHARTS, text)
self.assertNotIn(_GRAPH.split(':')[0], text.replace('رسم', ''))
class ExpandedKindsTests(SimpleTestCase):
"""نوع‌های تازه: هر کدام با یک نمونه‌ی واقعی و یک نمونه‌ی خراب."""
def test_constructed_points_and_new_items(self):
spec = validate({
'kind': 'plane', 'points': {
'A': [0, 0], 'B': [4, 0], 'C': [1, 3], 'M': {'mid': ['A', 'B']}, 'H': {'foot': ['C', 'A', 'B']},
'I': {'intersect': [['A', 'C'], ['B', [0, 3]]]}, 'T': {'along': ['A', 'B', 0.25]}, 'F': {'fx': ['x^2', 3]},
'X': {'mid': ['A']}, 'Y': {'fx': ['alert(1)', 2]},
},
'items': [
{'type': 'segment', 'from': 'A', 'to': 'M', 'ticks': 2, 'color': 'green'},
{'type': 'angle', 'vertex': 'A', 'from': 'B', 'to': 'C', 'arcs': 9},
{'type': 'image', 'of': ['A', 'B', 'C'], 'transform': {'rotate': {'center': 'C', 'deg': -90}}},
{'type': 'piecewise', 'pieces': [{'expr': 'x+1', 'from': -2, 'to': 1, 'closedTo': False}, {'expr': 'bad;', 'from': 1, 'to': 2}]},
{'type': 'segment', 'from': 'A', 'to': 'B', 'color': '#ff0000'},
],
})
self.assertEqual(set(spec['points']), {'A', 'B', 'C', 'M', 'H', 'I', 'T', 'F'})
self.assertEqual(spec['items'][1]['arcs'], 3)
self.assertEqual(spec['items'][2]['transform'], {'rotate': {'center': 'C', 'deg': -90.0}})
self.assertEqual(len(spec['items'][3]['pieces']), 1)
self.assertNotIn('color', spec['items'][4]) # فقط نامِ رنگِ شناخته‌شده
def test_solid_chart_venn_tree_mapping_grid_clock_table(self):
self.assertEqual(validate({'kind': 'solid', 'shape': 'cone', 'dims': {'r': 4, 'h': 12}, 'labels': {'r': '4 cm'}})['dims'], {'r': 4.0, 'h': 12.0})
self.assertIsNone(validate({'kind': 'solid', 'shape': 'teapot'}))
pie = validate({'kind': 'pie', 'labels': ['a', 'b', 'c'], 'values': [1, 2]})
self.assertEqual(pie['labels'], ['a', 'b'])
self.assertIsNone(validate({'kind': 'pie', 'labels': ['a'], 'values': [0]}))
venn = validate({'kind': 'venn', 'sets': ['A', 'B'], 'regions': {'AB': ['3'], 'AC': ['x'], 'out': ['6']}, 'shade': ['AB', 'Z']})
self.assertEqual(set(venn['regions']), {'AB', 'out'})
self.assertEqual(venn['shade'], ['AB'])
tree = validate({'kind': 'tree', 'root': {'label': 'شروع', 'children': [{'label': 'رو', 'p': '1/2', 'children': [{'label': 'a', 'children': [{'label': 'b', 'children': [{'label': 'too deep'}]}]}]}]}})
leaf = tree['root']['children'][0]['children'][0]['children'][0]
self.assertNotIn('children', leaf) # حداکثر سه سطح زیرِ ریشه
m = validate({'kind': 'mapping', 'left': ['1', '2'], 'right': ['3'], 'arrows': [[0, 0], [1, 5]]})
self.assertEqual(m['arrows'], [[0, 0]])
self.assertEqual(validate({'kind': 'grid', 'rows': 5, 'cols': 5, 'shaded': 99})['shaded'], 25)
self.assertEqual(validate({'kind': 'clock', 'hour': 3, 'minute': 25})['minute'], 25)
self.assertEqual(validate({'kind': 'table', 'headers': ['x', '1'], 'rows': [['y', '+'], 'bad']})['rows'], [['y', '+']])
def test_numberline_intervals_and_fraction_step(self):
nl = validate({'kind': 'numberline', 'min': 0, 'max': 2, 'step': 0.25, 'fractions': True,
'intervals': [{'from': -2, 'to': 3, 'closedFrom': True}, {'from': 1, 'to': None}, {'from': 3, 'to': 1}]})
self.assertEqual(nl['step'], 0.25)
self.assertEqual(nl['intervals'], [{'from': -2.0, 'to': 3.0, 'closedFrom': True}, {'from': 1.0, 'to': None}])
self.assertNotIn('step', validate({'kind': 'numberline', 'min': 0, 'max': 100, 'step': 0.01}))
class ModelSlipRepairTests(SimpleTestCase):
"""خطاهای قالبیِ واقعیِ gpt-4.1-mini در سنجشِ ۲۰۲۶-۰۹-۲۸؛ عیناً همان خروجی‌ها."""
def test_persian_digits_in_json_numbers(self):
from .services.diagram import parse
spec = parse('{"kind":"grid","title":"نمایش ۱۸ درصد","rows":۱۰,"cols":۱۰,"shaded":۱۸}')
self.assertEqual((spec['rows'], spec['cols'], spec['shaded']), (10, 10, 18))
def test_pi_expressions_as_numbers(self):
from .services.diagram import parse
spec = parse('{"kind":"plane","title":"t","axes":true,"items":[{"type":"function","expr":"sin(x)^2","from":-2*pi,"to":2*pi},'
'{"type":"vline","x":pi/2},{"type":"vline","x":3pi/4}]}')
self.assertAlmostEqual(spec['items'][0]['from'], -6.283185, places=5)
self.assertAlmostEqual(spec['items'][1]['x'], 1.570796, places=5)
self.assertAlmostEqual(spec['items'][2]['x'], 2.356194, places=5)
def test_bare_item_and_function_kind_become_a_plane(self):
from .services.diagram import parse
bare = parse('{"type":"function","expr":"sqrt(3*x - 5)","from":1.6,"to":5,"label":"f(x) = √(3x - 5)","marks":["yIntercept"]}')
self.assertEqual(bare['kind'], 'plane')
self.assertTrue(bare['axes'])
self.assertEqual(bare['items'][0]['expr'], 'sqrt(3*x - 5)')
kinded = parse('{"kind":"function","title":"نمودار تابع p(x)","axes":true,"xRange":[-3,3],"yRange":[-10,10],'
'"type":"function","expr":"x^3 - x^2 - 4*x + 4","from":-3,"to":3,"marks":["roots"]}')
self.assertEqual(kinded['kind'], 'plane')
self.assertEqual(kinded['xRange'], [-3.0, 3.0])
self.assertEqual(kinded['items'][0]['marks'], ['roots'])
def test_venn_with_persian_set_names(self):
from .services.diagram import parse
spec = parse('{"kind":"venn","title":"ون","sets":["والیبال","فوتبال"],"regions":{"والیبال":["7-x"],'
'"والیبال_فوتبال":["x"],"فوتبال":["4-x"],"out":[]},"shade":["والیبال_فوتبال"]}')
self.assertEqual(spec['sets'], ['A', 'B'])
self.assertEqual(spec['names'], ['والیبال', 'فوتبال'])
self.assertEqual(spec['regions'], {'A': ['7-x'], 'AB': ['x'], 'B': ['4-x'], 'out': []})
self.assertEqual(spec['shade'], ['AB'])
def test_unknown_kind_is_still_dropped(self):
from .services.diagram import parse
self.assertIsNone(parse('{"kind":"line_segment","points":[{"label":"مشهد","position":0}]}'))
def test_comments_after_values_and_wide_numberline(self):
from .services.diagram import parse
spec = parse('{"kind":"venn","sets":["A","B","C"],"regions":{"AC":["5"]},"shade":[\n "AC", // الف\n "B" // ب\n]}')
self.assertEqual(spec['shade'], ['AC', 'B'])
wide = parse('{"kind":"numberline","min":-۱۴۰,"max":۸۷,"step":۱۰,"points":[{"at":-۵۵,"label":"-۵۵"}]}')
self.assertEqual((wide['min'], wide['max'], wide['step']), (-140.0, 87.0, 10.0))
class SecondRoundKindsTests(SimpleTestCase):
"""افزوده‌های دورِ دومِ سنجش: خط از معادله، مقایسه‌ی کسرها، عددِ مخلوط، محرکِ ساعت."""
def test_line_from_equation(self):
spec = validate({'kind': 'plane', 'items': [{'type': 'line', 'eq': [3, 5, 4]}, {'type': 'line', 'eq': [0, 0, 1]}]})
self.assertEqual(spec['items'], [{'type': 'line', 'eq': [3.0, 5.0, 4.0]}])
def test_fraction_compare_and_wholes(self):
cmp = validate({'kind': 'fraction', 'compare': [{'parts': 5, 'shaded': 1, 'label': 'یک‌پنجم'}, {'parts': 10, 'shaded': 12}]})
self.assertEqual(cmp['compare'][1], {'parts': 10, 'shaded': 10})
mixed = validate({'kind': 'fraction', 'parts': 10, 'shaded': 7, 'wholes': 2})
self.assertEqual((mixed['wholes'], mixed['shaded']), (2, 7))
def test_clock_only_for_telling_time(self):
from .services.diagram import _CLOCK, hint_for
self.assertNotIn(_CLOCK, hint_for('فاطمه ۶ ساعت کتاب خواند. چند صفحه ماند؟'))
self.assertIn(_CLOCK, hint_for('عقربه‌ی ساعت‌شمار روی ۳ است. ساعت چند است؟'))
self.assertIn(_CLOCK, hint_for('ساعت ۳ و ۲۵ دقیقه را روی ساعت نشان بده'))