diff --git a/CLAUDE.md b/CLAUDE.md index 82ac6c3..54104ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -388,6 +388,22 @@ preferred setup, because there is then no key to leak. **Never let the server ru `django-insecure-…` fallback from `config/settings.py`: that string is committed here, so anyone who can read this repo can forge a token for any user.** +## وضعیت ۲۰۲۶-۰۹-۲۸ — شکل در دستیار، آزمونِ جزیره، همراهِ چهارم (هنوز دیپلوی نشده) + +- **شکل در جوابِ دستیار:** مدل در انتهای جواب یک بلوکِ `{JSON}` می‌نویسد؛ + `rag/services/diagram.py` آن را پیش از `mathtext` جدا و سخت‌گیرانه اعتبارسنجی می‌کند، روی + `Message.diagram` (migration `ai_tutor.0006`) می‌نشیند و در `done`ِ SSE و تاریخچه می‌آید. + فرانت (`components/Diagram.tsx`) خودش SVG می‌کشد؛ HTML/SVGِ مدل هرگز وارد صفحه نمی‌شود. + پرامپت `tutor-v6-diagrams`. سنجش با gpt-4.1-mini: قاعده فقط در پرامپتِ سیستم ۳ از ۱۲ + شکل می‌داد؛ با یادآوریِ کنارِ سوال (`hint_for`، بر اساسِ کلمه‌های شکل‌دار) ۱۲ از ۱۲، و ۰ از ۸ + برای سوالِ بی‌شکل. فقط ~۶۰ فراخوانی روی ۸ سوال سنجیده شده. +- **فرانت آزمونِ جزیره را بازی می‌کند** (`/nodes/{id}/quiz`): هر درس فقط یک سوال دارد و + درس‌به‌درس یعنی ~۱۱ دور برای هر جزیره. خانه حالا خودِ مسیر است با دکمه‌ی «شروع». +- **همراهِ چهارم «تدی»** (شکلکِ `mobile-preview/mascot-pack`، شش حالت) با migration + `progress.0005` (slugِ `tedi`، ۶۰۰ امتیاز؛ اسم و امتیاز تصمیمِ محصولی). +- محلی: `PUBLIC_BASE_URL` پیش‌فرضش دامنه‌ی اصلی است، پس «پرداخت» روی لپ‌تاپ به سرورِ واقعی + می‌رود و ۴۰۴ می‌گیرد؛ باگِ سرور نیست. گزارش: `~/Desktop/گزارشات Tadrisino/گزارش-۲۵/`. + ## وضعیت ۲۰۲۶-۰۹-۲۷ — دامنه‌ی تازه و فرانتِ تازه، هر دو زنده - ✅ **پیامکِ کدِ ورود واقعاً می‌رسد** (۲۱:۴۴). اسمِ الگو را سینیور داد: `SMSVerify`، با همان سه diff --git a/ai_tutor/migrations/0006_message_diagram.py b/ai_tutor/migrations/0006_message_diagram.py new file mode 100644 index 0000000..2e9bc68 --- /dev/null +++ b/ai_tutor/migrations/0006_message_diagram.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-09-28 12:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai_tutor', '0005_message_chunk_count_message_completion_tokens_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='message', + name='diagram', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/ai_tutor/models.py b/ai_tutor/models.py index 62a4899..2a20d3e 100644 --- a/ai_tutor/models.py +++ b/ai_tutor/models.py @@ -43,6 +43,8 @@ class Message(models.Model): completion_tokens = models.PositiveIntegerField(default=0) latency_ms = models.PositiveIntegerField(default=0) from_cache = models.BooleanField(default=False) + # شکلِ پاسخ (rag.services.diagram)؛ فقط توصیفِ JSONِ اعتبارسنجی‌شده، نه SVG. + diagram = models.JSONField(null=True, blank=True) class Meta: ordering = ['created_at'] diff --git a/ai_tutor/serializers.py b/ai_tutor/serializers.py index 55b16a4..6321e05 100644 --- a/ai_tutor/serializers.py +++ b/ai_tutor/serializers.py @@ -35,6 +35,7 @@ class AskResponseSerializer(serializers.Serializer): answer = serializers.CharField() suggestedFollowUps = serializers.ListField(child=serializers.CharField()) remainingQuota = serializers.IntegerField() + diagram = serializers.DictField(required=False, allow_null=True) # تکه‌های درسیِ بازیابی‌شده از Qdrant که پاسخ بر پایه‌شون ساخته شده؛ # خالی یعنی retrieval انجام نشده (نبودِ subject/lessonNumber یا خاموش‌بودنِ RAG). usedChunks = UsedChunkSerializer(many=True, required=False, default=list) @@ -46,6 +47,8 @@ class MessageSerializer(serializers.Serializer): messageId = serializers.CharField() role = serializers.CharField() text = serializers.CharField() + # شکلِ پاسخ؛ شِمایش در rag/services/diagram.py (plane / numberline / fraction). + diagram = serializers.DictField(required=False, allow_null=True) class ConversationMessagesResponseSerializer(serializers.Serializer): diff --git a/ai_tutor/views.py b/ai_tutor/views.py index ee923b5..720337b 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -54,6 +54,8 @@ def _annotation(retrieval_meta: dict, generation_meta: dict, *, from_cache: bool 'completion_tokens': int(generation_meta.get('completionTokens') or 0), 'latency_ms': int(generation_meta.get('latencyMs') or 0), 'from_cache': bool(from_cache), + # شکلِ اعتبارسنجی‌شده در rag.services.diagram؛ کشِ قدیمی آن را ندارد. + 'diagram': generation_meta.get('diagram') if isinstance(generation_meta.get('diagram'), dict) else None, } @@ -461,6 +463,7 @@ class AskAIView(APIView): 'answer': answer_text, 'suggestedFollowUps': follow_ups, 'remainingQuota': remaining_quota, + 'diagram': assistant_message.diagram, 'usedChunks': [ {'title': c.get('title', ''), 'summary': c.get('summary', '')} for c in used_chunks @@ -486,7 +489,7 @@ class AskAIView(APIView): نسخه‌ی SSE از post(): همون منطقِ retrieval + کوتا + idempotency، ولی پاسخ توکن‌به‌توکن استریم می‌شه. رویدادها: event: chunk data: {"delta": "..."} - event: done data: {conversationId, messageId, remainingQuota, usedChunks[, retrievalDebug]} + event: done data: {conversationId, messageId, remainingQuota, diagram, usedChunks[, retrievalDebug]} event: error data: {"detail": "..."} فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده. """ @@ -529,11 +532,12 @@ class AskAIView(APIView): for c in used_chunks ] parts: list[str] = [] + stream_result: dict = {} started = time.monotonic() effective_grade = retrieval_meta.get('gradeTitle') or grade_title or '' for delta in stream_answer( v['userQuestion'], used_chunks or None, effective_grade, - history=history, focus=focus, + history=history, focus=focus, result=stream_result, ): parts.append(delta) yield _sse('chunk', {'delta': delta}) @@ -548,6 +552,7 @@ class AskAIView(APIView): 'promptVersion': PROMPT_VERSION, 'latencyMs': int((time.monotonic() - started) * 1000), 'hadContext': bool(used_chunks), + 'diagram': stream_result.get('diagram'), } if cacheable: answer_cache.set(cache_key, { @@ -591,6 +596,7 @@ class AskAIView(APIView): 'messageId': str(existing.assistant_message_id), 'suggestedFollowUps': existing.suggested_follow_ups, 'remainingQuota': max(DAILY_AI_QUOTA - used_today, 0), + 'diagram': existing.assistant_message.diagram, 'usedChunks': [], }) return @@ -601,6 +607,7 @@ class AskAIView(APIView): 'messageId': str(assistant_message.id), 'suggestedFollowUps': follow_ups, 'remainingQuota': max(DAILY_AI_QUOTA - used_today, 0), + 'diagram': assistant_message.diagram, 'usedChunks': [ {'title': c.get('title', ''), 'summary': c.get('summary', '')} for c in used_chunks @@ -663,6 +670,7 @@ class AskAIView(APIView): 'answer': existing.assistant_message.text, 'suggestedFollowUps': existing.suggested_follow_ups, 'remainingQuota': remaining_quota, + 'diagram': existing.assistant_message.diagram, } return AskResponseSerializer(data) @@ -683,7 +691,8 @@ class ConversationMessagesView(APIView): 'conversationId': str(conversation.id), 'lessonId': str(conversation.lesson_id) if conversation.lesson_id else None, 'messages': [ - {'messageId': str(m.id), 'role': m.role, 'text': m.text} for m in messages + {'messageId': str(m.id), 'role': m.role, 'text': m.text, 'diagram': m.diagram} + for m in messages ], } return Response(ConversationMessagesResponseSerializer(data).data) diff --git a/progress/migrations/0005_tedi_character.py b/progress/migrations/0005_tedi_character.py new file mode 100644 index 0000000..4b52f61 --- /dev/null +++ b/progress/migrations/0005_tedi_character.py @@ -0,0 +1,25 @@ +""" +همراهِ چهارم: «تدی»، شکلکِ زردِ پیش‌نمایشِ موبایل (mobile-preview/mascot-pack) که +حالا در فرانت کنارِ سه‌یار است و همان شش حالتِ چهره را دارد. + +نام و امتیازِ باز شدن تصمیمِ محصولی‌اند و از ادمین قابلِ تغییرند؛ ۶۰۰ یعنی بعد از +پی (۳۰۰)، حدودِ ۶۰ جوابِ درست. get_or_create است، پس ویرایشِ بعدیِ ادمین را +برنمی‌گرداند. فرانت فقط slugِ tedi را به این شکلک نگاشت می‌کند. +""" +from django.db import migrations + + +def create(apps, schema_editor): + apps.get_model('progress', 'Character').objects.get_or_create( + slug='tedi', + defaults={'name': 'تدی', 'icon': 'tedi', 'unlock_xp': 600, 'order': 4}, + ) + + +def remove(apps, schema_editor): + apps.get_model('progress', 'Character').objects.filter(slug='tedi').delete() + + +class Migration(migrations.Migration): + dependencies = [('progress', '0004_seyar_characters')] + operations = [migrations.RunPython(create, remove)] diff --git a/progress/tests.py b/progress/tests.py index 71768e0..2a47000 100644 --- a/progress/tests.py +++ b/progress/tests.py @@ -255,3 +255,14 @@ class RebuildLeaderboardCommandTests(TestCase): call_command('rebuild_leaderboard', '--period', 'all_time', '--dry-run', verbosity=0) self.assertEqual(LeaderboardEntry.objects.count(), 0) + + +class FourCompanionsCatalogTests(TestCase): + """migrationهای 0004 و 0005: سه‌یار و تدی همان چهار slugی‌اند که فرانت می‌شناسد.""" + + def test_catalog_has_the_four_companions_in_order(self): + rows = list( + Character.objects.filter(slug__in=['sigma', 'delta', 'pi', 'tedi']) + .order_by('order').values_list('slug', 'unlock_xp') + ) + self.assertEqual(rows, [('sigma', 0), ('delta', 100), ('pi', 300), ('tedi', 600)]) diff --git a/rag/services/diagram.py b/rag/services/diagram.py new file mode 100644 index 0000000..c1a1a42 --- /dev/null +++ b/rag/services/diagram.py @@ -0,0 +1,303 @@ +""" +شکلِ پاسخِ دستیار: مدل یک توصیفِ JSONِ کوتاه در … می‌نویسد و +اپ خودش آن را به 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 = '', '' +_BLOCK = re.compile(r'(.*?)(?:|$)', re.S) + +DIAGRAM_RULE = ( + 'شکل (اپ این را به تصویر تبدیل می‌کند):\n' + '- وقتی سوال درباره‌ی یکی از این‌هاست، در پایانِ پاسخ دقیقاً یک بلوکِ ' + '… بگذار: شکلِ هندسی (مثلث، چندضلعی، دایره، زاویه، خطِ موازی)، ' + 'دایره‌ی مثلثاتی، حرکت یا مقایسه روی محورِ اعداد، نقطه یا نمودار در صفحه‌ی مختصات، ' + 'کسر یا درصد به‌صورتِ تکه‌های یک شکل. هر سوالی که کلمه‌ی مثلث، دایره، زاویه، محور، ' + 'نمودار، کسر یا تکه دارد، شکل می‌خواهد. فقط برای حسابِ عددی یا ساده‌کردنِ عبارتِ ' + 'جبری که شکلی ندارد، شکل نگذار.\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 = ( + 'این سوال شکل دارد: در پایانِ پاسخ بلوکِ … را طبقِ قاعده‌ی «شکل» بگذار.' +) + + +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: + """ + متنِ استریم را عبور می‌دهد و بلوکِ را کنار می‌گذارد. تهِ هر تکه که + ممکن است آغازِ «» باشد نگه داشته می‌شود، چون برچسب می‌تواند بینِ + دو تکه‌ی استریم نصف شود. + """ + + 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 diff --git a/rag/services/generation.py b/rag/services/generation.py index 76ebbaa..fa091cf 100644 --- a/rag/services/generation.py +++ b/rag/services/generation.py @@ -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 diff --git a/rag/tests_diagram.py b/rag/tests_diagram.py new file mode 100644 index 0000000..5616d4d --- /dev/null +++ b/rag/tests_diagram.py @@ -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\n' + json.dumps(CIRCLE, ensure_ascii=False) + '\n' + + +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': ''}]}) + 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{"kind": "plane", ') + self.assertEqual((text, spec), ('جواب ۵ است.', None)) + + def test_unclosed_block_never_leaks_json(self): + text, _ = extract('جواب.\n{"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('الف {"kind":"fraction","parts":2,"shaded":1} ب') + 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('', 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 خواسته شده