- remove the family app from the root (separate parent accounts, invite codes, parental settings); progress.0006 drops its tables, permissions and migration history, and Profile.parent_phone, the two family notification types and ?userId= on /api/progress/nodes go with it - GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes, questions, accuracy, islands, tutor questions) for the "parent report" page - profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it - GET /api/payment/invoices; orders now snapshot list price and plan title - tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11), refusal marker replaced with fixed text; self-harm gets a help line instead - streamed answers now record token usage (it was always 0) - daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota - delete_users management command for wiping test accounts across every app - docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
464 lines
27 KiB
Python
464 lines
27 KiB
Python
"""شکلِ پاسخِ دستیار: جدا کردن از متن، اعتبارسنجی، و رسیدنش تا 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'])
|
||
from .services.generation import PROMPT_VERSION
|
||
self.assertEqual(message.prompt_version, PROMPT_VERSION)
|
||
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('ساعت ۳ و ۲۵ دقیقه را روی ساعت نشان بده'))
|
||
|
||
|
||
class FollowUpHintTests(SimpleTestCase):
|
||
"""«اگه بهجای ۶ بذاریم ۴ چی؟» بعد از یک سوالِ شکلدار هم شکلِ تازه میخواهد."""
|
||
|
||
def test_follow_up_without_shape_words_gets_the_soft_hint(self):
|
||
from .services.diagram import FOLLOW_UP_HINT, _GRAPH
|
||
from .services.generation import build_messages
|
||
|
||
history = [{'role': 'user', 'text': 'ریشهها و رأسِ سهمیِ y = x² − 5x + 6 را پیدا کن'},
|
||
{'role': 'assistant', 'text': '…'}]
|
||
last = build_messages('اگه بهجای ۶ بذاریم ۴ چی میشه؟', None, 'یازدهم', history=history)[-1]['content']
|
||
self.assertIn(FOLLOW_UP_HINT, last)
|
||
self.assertIn(_GRAPH, last)
|
||
|
||
def test_no_hint_without_a_shape_question_before(self):
|
||
from .services.diagram import FOLLOW_UP_HINT
|
||
from .services.generation import build_messages
|
||
|
||
history = [{'role': 'user', 'text': 'حاصلِ ۲۳ × ۴۵ چند است؟'}, {'role': 'assistant', 'text': '۱۰۳۵'}]
|
||
last = build_messages('اگه ۲۴ باشه چی؟', None, 'هشتم', history=history)[-1]['content']
|
||
self.assertNotIn(FOLLOW_UP_HINT, last)
|
||
|
||
def test_a_shape_question_keeps_the_strong_hint(self):
|
||
from .services.diagram import DIAGRAM_HINT, FOLLOW_UP_HINT
|
||
from .services.generation import build_messages
|
||
|
||
history = [{'role': 'user', 'text': 'مثلثی با ضلعهای ۳ و ۴'}]
|
||
last = build_messages('حالا دایرهای به شعاعِ ۲', None, 'نهم', history=history)[-1]['content']
|
||
self.assertIn(DIAGRAM_HINT, last)
|
||
self.assertNotIn(FOLLOW_UP_HINT, last)
|
||
|
||
def test_explicit_request_without_topic_words_gets_the_whole_catalog(self):
|
||
from .services.diagram import ASKED_HINT, DIAGRAM_SPEC, asked_hint_for
|
||
from .services.generation import build_messages
|
||
|
||
last = build_messages('یه جعبهی کادو دارم، شکلش رو برام بکش', None, 'ششم')[-1]['content']
|
||
self.assertIn(ASKED_HINT, last)
|
||
self.assertIn(DIAGRAM_SPEC, last)
|
||
for q in ('مشکل دارم با این سوال', 'میخوام یه سوال بپرسم', '۳۴۵ × ۱۲'):
|
||
self.assertEqual(asked_hint_for(q), '', q)
|
||
|
||
|
||
class ThirdRoundTests(SimpleTestCase):
|
||
"""از چتِ واقعی با gpt-4.1-mini (۲۰۲۶-۰۹-۲۹): خط، چندضلعیِ منتظم، زاویهی خارجی، کسرِ کلمهای."""
|
||
|
||
def test_line_as_written_equation(self):
|
||
spec = validate({'kind': 'plane', 'items': [
|
||
{'type': 'line', 'equation': '3x + 5y = 4'}, {'type': 'line', 'equation': 'y = 12x − 16'},
|
||
{'type': 'line', 'equation': 'y = sin(x)'}, {'type': 'line', 'equation': 'x + y'}]})
|
||
self.assertEqual([i.get('equation') for i in spec['items']], ['3x + 5y = 4', 'y = 12x - 16'])
|
||
|
||
def test_eq_array_is_general_form(self):
|
||
# عینِ خروجیِ مدل برای مماسِ y = 12x − 16
|
||
spec = validate({'kind': 'plane', 'items': [{'type': 'line', 'eq': [-12, 1, 16]}]})
|
||
self.assertEqual(spec['items'][0]['eq'], [-12.0, 1.0, 16.0])
|
||
|
||
def test_regular_polygon_and_exterior_angle(self):
|
||
spec = validate({'kind': 'plane', 'items': [
|
||
{'type': 'regularPolygon', 'n': 50, 'r': 2, 'names': ['A', 'B', 'C', 'D', 'E', 'bad name!']},
|
||
{'type': 'exteriorAngle', 'vertex': 'B', 'from': 'A', 'to': 'C', 'label': '72°'}]})
|
||
self.assertEqual(spec['items'][0]['n'], 12)
|
||
self.assertEqual(spec['items'][0]['names'], ['A', 'B', 'C', 'D', 'E'])
|
||
self.assertEqual(spec['items'][1]['type'], 'exteriorAngle')
|
||
|
||
def test_fraction_words_pull_the_fraction_section(self):
|
||
from .services.diagram import _PARTS, hint_for
|
||
|
||
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))
|