diff --git a/.gitignore b/.gitignore index 093de53..4ec2cd5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ staticfiles/ # دیتابیسِ فایلیِ تست‌ها (config/settings.py → DATABASES.TEST) .test-db.sqlite3* +tools/fake_openai_requests.jsonl diff --git a/rag/services/diagram.py b/rag/services/diagram.py index 445ec07..44655ee 100644 --- a/rag/services/diagram.py +++ b/rag/services/diagram.py @@ -148,6 +148,27 @@ DIAGRAM_HINT = ( ) +FOLLOW_UP_HINT = ( + 'اگر این سوال ادامه‌ی همان مسئله‌ی شکل‌دارِ قبلی است (عدد یا اندازه عوض شده، یا توضیحِ بیشتر ' + 'خواسته)، در پایانِ پاسخ شکلِ تازه را با عددهای تازه در بلوکِ … بکش؛ ' + 'اگر نیست، شکل نگذار.' +) + + +def follow_up_hint_for(question: str, previous: str) -> str: + """ + سوالِ ادامه‌ای بی‌کلمه‌ی شکل‌دار («اگه به‌جای ۶ بذاریم ۴ چی؟») بعد از یک سوالِ شکل‌دار: + یادآوریِ نرم (نه «این سوال شکل دارد») و بخش‌های کاتالوگِ همان سوالِ قبلی. نرم است چون + «مرسی» بعد از یک سوالِ هندسه نباید شکل بگیرد. + """ + if not previous or not (question or '').strip(): + return '' + parts = [section for pattern, section in SECTIONS if pattern.search(previous)] + if not parts: + return '' + return '\n'.join([FOLLOW_UP_HINT, _HEAD, *parts]) + + def hint_for(question: str) -> str: """یادآوری و فقط آن بخش‌هایی از کاتالوگ که به همین سوال می‌خورند؛ سوالِ بی‌شکل → ''.""" text = question or '' diff --git a/rag/services/generation.py b/rag/services/generation.py index fa091cf..a7de4e2 100644 --- a/rag/services/generation.py +++ b/rag/services/generation.py @@ -176,9 +176,10 @@ _SLASH_READING = ( def _user_prompt(question: str, contexts: list[str], focus: str = '', - primary: bool = False) -> str: + primary: bool = False, previous: str = '') -> str: asked = f'سوالِ دانش‌آموز: {question}' - shape_hint = diagram_mod.hint_for(f'{question}\n{focus}') + shape_hint = (diagram_mod.hint_for(f'{question}\n{focus}') + or diagram_mod.follow_up_hint_for(question, previous)) if shape_hint: asked = f'{asked}\n\n{shape_hint}' slash_rule = primary and '/' in question @@ -250,8 +251,11 @@ def build_messages(question: str, context_chunks: list[dict] | None, text = (turn.get('text') or '').strip() if text: messages.append({'role': role, 'content': text[:_HISTORY_CHARS]}) + # سوالِ قبلیِ کاربر: «اگه به‌جای ۶ بذاریم ۴ چی؟» کلمه‌ی شکل‌دار ندارد، ولی ادامه‌ی + # همان مسئله‌ی شکل‌دار است و شکلش باید با عددِ تازه دوباره کشیده شود. + previous = next((t.get('text') or '' for t in reversed(history or []) if t.get('role') == 'user'), '') messages.append({'role': 'user', 'content': _user_prompt( - question, contexts, focus, primary=grade_tier(grade_title) == 'primary')}) + question, contexts, focus, primary=grade_tier(grade_title) == 'primary', previous=previous)}) return messages diff --git a/rag/tests_diagram.py b/rag/tests_diagram.py index 5365896..f492c59 100644 --- a/rag/tests_diagram.py +++ b/rag/tests_diagram.py @@ -320,3 +320,34 @@ class SecondRoundKindsTests(SimpleTestCase): 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) diff --git a/tools/fake_openai.py b/tools/fake_openai.py new file mode 100644 index 0000000..97692dd --- /dev/null +++ b/tools/fake_openai.py @@ -0,0 +1,170 @@ +""" +سرورِ جعلیِ OpenAI برای تستِ کاملِ دستیار بدونِ اعتبارِ GapGPT (فقط لوکال، هرگز روی سرور). + + python tools/fake_openai.py # روی 127.0.0.1:9911 + OPENAI_BASE_URL=http://127.0.0.1:9911/v1 DJANGO_DEBUG=1 RAG_ANSWER_CACHE_TTL=0 \ + python manage.py runserver + +همه‌ی مسیرِ ما واقعی است — پرامپت‌سازی، استریم، جدا کردن و تعمیرِ بلوکِ شکل، ذخیره، +تاریخچه و رسم در فرانت — و فقط خودِ مدل جعلی است. پس رفتارِ مدل (اینکه شکل را درست +انتخاب می‌کند یا نه) را نمی‌سنجد؛ آن را فقط با مدلِ واقعی می‌شود سنجید. ساخته‌شده ۲۰۲۶-۰۹-۲۹، +وقتی اعتبارِ GapGPT تمام بود. + +/v1/chat/completions (استریم و بی‌استریم) و /v1/embeddings را جواب می‌دهد. جواب‌ها +دست‌نویس‌اند، در نقشِ gpt-4.1-mini و طبقِ همان پرامپتِ دستیار (متنِ مرحله‌به‌مرحله + +بلوکِ ). هر درخواستِ رسیده در requests.jsonl ثبت می‌شود تا ساختارِ پیامِ +فرستاده‌شده به مدل دیده شود. +""" +import json, random, re, time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +LOG = Path(__file__).with_name('fake_openai_requests.jsonl') # در .gitignore +D = lambda obj: '\n\n' + json.dumps(obj, ensure_ascii=False) + '\n' # noqa: E731 + +SCRIPTS = [ + ('ضلع‌های قائمه ۳ و ۴', '''۱) در مثلثِ قائم‌الزاویه طبقِ قضیه‌ی فیثاغورس: وتر² = ضلعِ اول² + ضلعِ دوم² +۲) وتر² = 3² + 4² = 9 + 16 = 25 +۳) وتر = √25 = 5 +وارسی: 3² + 4² = 25 و 5² = 25 ✔ +در شکلِ پایین مثلث با ضلع‌های ۳ و ۴ و وترِ ۵ کشیده شده است. +✅ جواب: وتر برابر 5 است. +حالا خودت امتحان کن: اگر ضلع‌های قائمه ۵ و ۱۲ باشند، وتر چند است؟''' + D({ + 'kind': 'plane', 'title': 'مثلثِ قائم‌الزاویه با ضلع‌های ۳ و ۴', 'axes': False, + 'points': {'A': [0, 0], 'B': [4, 0], 'C': [0, 3]}, + 'items': [{'type': 'polygon', 'points': ['A', 'B', 'C']}, {'type': 'rightAngle', 'vertex': 'A', 'from': 'B', 'to': 'C'}, + {'type': 'segment', 'from': 'A', 'to': 'B', 'label': '4'}, {'type': 'segment', 'from': 'A', 'to': 'C', 'label': '3'}, + {'type': 'segment', 'from': 'B', 'to': 'C', 'label': 'وتر = 5', 'color': 'red'}]})), + ('۶ و ۸', '''۱) همان قضیه‌ی فیثاغورس: وتر² = 6² + 8² = 36 + 64 = 100 +۲) وتر = √100 = 10 +وارسی: 10² = 100 ✔ — ضلع‌ها دو برابر شدند، پس وتر هم دو برابر شد (5 × 2 = 10). +در شکلِ پایین مثلثِ تازه با ضلع‌های ۶ و ۸ کشیده شده است. +✅ جواب: وتر برابر 10 است.''' + D({ + 'kind': 'plane', 'title': 'مثلثِ قائم‌الزاویه با ضلع‌های ۶ و ۸', 'axes': False, + 'points': {'A': [0, 0], 'B': [8, 0], 'C': [0, 6]}, + 'items': [{'type': 'polygon', 'points': ['A', 'B', 'C']}, {'type': 'rightAngle', 'vertex': 'A', 'from': 'B', 'to': 'C'}, + {'type': 'segment', 'from': 'A', 'to': 'B', 'label': '8'}, {'type': 'segment', 'from': 'A', 'to': 'C', 'label': '6'}, + {'type': 'segment', 'from': 'B', 'to': 'C', 'label': 'وتر = 10', 'color': 'red'}]})), + ('x² - 5x + 6', '''۱) ریشه‌ها: x² − 5x + 6 = 0 را تجزیه می‌کنیم: (x − 2)(x − 3) = 0، پس x = 2 یا x = 3. +۲) رأس: طولِ رأس x = −b/(2a) = 5/2 = 2.5 و عرضش y = 2.5² − 5(2.5) + 6 = 6.25 − 12.5 + 6 = −0.25 +۳) عرض از مبدأ: به ازای x = 0، y = 6. +وارسی: 2² − 5(2) + 6 = 0 و 3² − 5(3) + 6 = 0 ✔ +در شکلِ پایین ریشه‌ها، رأس و محورِ تقارنِ x = 2.5 نشان داده شده‌اند. +✅ جواب: ریشه‌ها 2 و 3، رأس (2.5, −0.25).''' + D({ + 'kind': 'plane', 'title': 'سهمیِ y = x² − 5x + 6', 'axes': True, 'points': {}, + 'items': [{'type': 'function', 'expr': 'x^2-5*x+6', 'from': -0.5, 'to': 5.5, 'label': 'y = x² − 5x + 6', + 'marks': ['roots', 'vertex', 'axis', 'yIntercept']}]})), + ('به جای ۶ بذاریم ۴', '''۱) تابعِ تازه y = x² − 5x + 4 است. تجزیه: (x − 1)(x − 4) = 0، پس ریشه‌ها x = 1 و x = 4. +۲) طولِ رأس تغییر نمی‌کند: x = 5/2 = 2.5 (چون a و b همان‌اند)؛ عرضش y = 6.25 − 12.5 + 4 = −2.25 +۳) سهمی ۲ واحد پایین‌تر آمد (6 → 4)، برای همین ریشه‌ها از هم دورتر شدند. +وارسی: 1 − 5 + 4 = 0 و 16 − 20 + 4 = 0 ✔ +در شکلِ پایین سهمیِ تازه با ریشه‌ها و رأسِ تازه آمده است. +✅ جواب: ریشه‌ها 1 و 4، رأس (2.5, −2.25).''' + D({ + 'kind': 'plane', 'title': 'سهمیِ y = x² − 5x + 4', 'axes': True, 'points': {}, + 'items': [{'type': 'function', 'expr': 'x^2-5*x+4', 'from': -0.5, 'to': 5.5, 'label': 'y = x² − 5x + 4', + 'marks': ['roots', 'vertex', 'axis', 'yIntercept']}]})), + ('استوانه‌ای با شعاع ۳', '''۱) حجمِ استوانه = مساحتِ قاعده × ارتفاع = πr²h +۲) = π × 3² × 5 = π × 9 × 5 = 45π +۳) با π ≈ 3.14: حجم ≈ 45 × 3.14 = 141.3 +وارسی: 9 × 5 = 45 ✔ +در شکلِ پایین استوانه با شعاعِ ۳ و ارتفاعِ ۵ کشیده شده است. +✅ جواب: 45π ≈ 141.3 واحدِ مکعب.''' + D({ + 'kind': 'solid', 'title': 'استوانه با r = 3 و h = 5', 'shape': 'cylinder', 'dims': {'r': 3, 'h': 5}, 'labels': {'r': 'r = 3', 'h': 'h = 5'}})), + ('2x − 1 > 3', '''۱) 2x − 1 > 3 ⟸ دو طرف را با 1 جمع می‌کنیم: 2x > 4 +۲) دو طرف را بر 2 تقسیم می‌کنیم (عددِ مثبت، جهتِ نامساوی عوض نمی‌شود): x > 2 +۳) خودِ 2 جواب نیست (نامساویِ اکید)، پس روی محور دایره‌ی توخالی می‌خورد. +وارسی: x = 3 ⟸ 2(3) − 1 = 5 > 3 ✔ و x = 2 ⟸ 3 > 3 نادرست ✔ +در شکلِ پایین بازه‌ی (2, +∞) روی محور آمده است. +✅ جواب: x > 2 یعنی بازه‌ی (2, +∞).''' + D({ + 'kind': 'numberline', 'title': 'جوابِ 2x − 1 > 3', 'min': -2, 'max': 6, 'points': [], 'jumps': [], + 'intervals': [{'from': 2, 'to': None, 'closedFrom': False, 'label': '(2, +∞)'}]})), + ('سه‌چهارم بزرگ‌تره یا دوسوم', '''۱) برای مقایسه، هر دو کسر را هم‌مخرج می‌کنیم. مخرجِ مشترکِ ۴ و ۳ عددِ ۱۲ است. +۲) سه‌چهارم = نُه‌دوازدهم (۳ × ۳ = ۹) و دوسوم = هشت‌دوازدهم (۲ × ۴ = ۸) +۳) نُه‌دوازدهم از هشت‌دوازدهم بیشتر است. +وارسی: در شکلِ پایین دو نوارِ هم‌اندازه داریم؛ قسمتِ رنگیِ سه‌چهارم کمی بلندتر است ✔ +✅ جواب: سه‌چهارم بزرگ‌تر است. +حالا خودت امتحان کن: یک‌دوم بزرگ‌تر است یا دوپنجم؟''' + D({ + 'kind': 'fraction', 'title': 'سه‌چهارم و دوسوم', 'shape': 'rect', 'parts': 4, 'shaded': 3, + 'compare': [{'parts': 4, 'shaded': 3, 'label': 'سه‌چهارم'}, {'parts': 3, 'shaded': 2, 'label': 'دوسوم'}]})), + ('مماس بر y = x³', '''۱) شیبِ مماس برابرِ مشتق در همان نقطه است: f′(x) = 3x²، پس f′(1) = 3 +۲) نقطه‌ی تماس: f(1) = 1³ = 1، یعنی (1, 1) +۳) معادله‌ی خط: y − 1 = 3(x − 1) ⟸ y = 3x − 2 +وارسی: خط از (1, 1) می‌گذرد: 3(1) − 2 = 1 ✔ +در شکلِ پایین نمودار و خطِ مماس در x = 1 آمده است. +✅ جواب: y = 3x − 2''' + D({ + 'kind': 'plane', 'title': 'مماس بر y = x³ در x = 1', 'axes': True, 'points': {}, + 'items': [{'type': 'function', 'expr': 'x^3', 'from': -1.6, 'to': 1.8, 'label': 'y = x³'}, + {'type': 'tangent', 'expr': 'x^3', 'at': 1, 'label': 'y = 3x − 2'}]})), + ('زاویه‌ی خارجیِ شش‌ضلعیِ منتظم', '''۱) مجموعِ زاویه‌های خارجیِ هر چندضلعیِ محدب ۳۶۰ درجه است. +۲) در شش‌ضلعیِ منتظم همه‌ی زاویه‌های خارجی برابرند: 360 ÷ 6 = 60 +۳) زاویه‌ی داخلی هم 180 − 60 = 120 می‌شود. +وارسی: 6 × 60 = 360 ✔ +در شکلِ پایین ضلعِ AB امتداد داده شده و زاویه‌ی خارجیِ رأسِ B علامت خورده است. +✅ جواب: 60 درجه.''' + D({ + 'kind': 'plane', 'title': 'زاویه‌ی خارجیِ شش‌ضلعیِ منتظم', 'axes': False, + 'points': {'A': {'polar': [2, 240]}, 'B': {'polar': [2, 300]}, 'C': {'polar': [2, 0]}, 'D': {'polar': [2, 60]}, + 'E': {'polar': [2, 120]}, 'F': {'polar': [2, 180]}, '_X': {'along': ['A', 'B', 1.6]}}, + 'items': [{'type': 'polygon', 'points': ['A', 'B', 'C', 'D', 'E', 'F']}, {'type': 'segment', 'from': 'B', 'to': '_X', 'dashed': True}, + {'type': 'angle', 'vertex': 'B', 'from': '_X', 'to': 'C', 'label': '60°'}]})), + ('A = {1, 2, 3, 4}', '''۱) اشتراک یعنی عضوهایی که در هر دو مجموعه هستند: A ∩ B = {3, 4} +۲) اجتماع یعنی همه‌ی عضوهای هر دو، بدونِ تکرار: A ∪ B = {1, 2, 3, 4, 5, 6} +وارسی: 3 و 4 در هر دو هستند ✔؛ اجتماع ۶ عضو دارد ✔ +در نمودارِ ون پایین ناحیه‌ی اشتراک رنگ شده است. +✅ جواب: A ∩ B = {3, 4} و A ∪ B = {1, 2, 3, 4, 5, 6}''' + D({ + 'kind': 'venn', 'title': 'A ∩ B = {3, 4}', 'sets': ['A', 'B'], 'regions': {'A': ['1', '2'], 'AB': ['3', '4'], 'B': ['5', '6']}, 'shade': ['AB']})), + # خطای قالبیِ واقعی (رقمِ فارسی و 2*pi در JSON) تا تعمیرِ سمتِ سرور در مسیرِ زنده دیده شود + ('y = sin x رو از', '''۱) sin x هر جا صفر است که x مضربی از π باشد: x = kπ +۲) در بازه‌ی −2π تا 2π این‌ها هستند: −2π، −π، 0، π، 2π +۳) بیشترین مقدار 1 در x = π/2 و −3π/2، و کمترین −1 در x = −π/2 و 3π/2 +در شکلِ پایین نمودار با ریشه‌ها و بیشینه و کمینه آمده است. +✅ جواب: ریشه‌ها −2π، −π، 0، π، 2π''' + '\n\n{"kind":"plane","title":"نمودارِ y = sin x","axes":true,"trig":true,"points":{},"items":[{"type":"function","expr":"sin(x)","from":-2*pi,"to":2*pi,"label":"y = sin x","marks":["roots","extrema"]},{"type":"hline","y":۱,"dashed":true,"color":"gray"}]}\n'), + ('۲۵ × ۴', '''۱) 25 × 4 یعنی چهار تا ۲۵: 25 + 25 + 25 + 25 +۲) 25 + 25 = 50 و 50 + 50 = 100 +وارسی: 100 ÷ 4 = 25 ✔ +✅ جواب: 100'''), +] +DEFAULT = 'جوابِ آزمایشیِ سرورِ جعلی. (سوالی که برایش جواب ننوشته بودم.)' + + +def answer_for(messages): + last = next((m['content'] for m in reversed(messages) if m['role'] == 'user'), '') + asked = last.split('سوالِ دانش‌آموز:', 1)[-1].split('\n\n', 1)[0] + for key, text in SCRIPTS: + if key in asked: + return text + return DEFAULT + + +class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _json(self, obj, code=200): + body = json.dumps(obj).encode() + self.send_response(code); self.send_header('Content-Type', 'application/json'); self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body) + + def do_POST(self): + req = json.loads(self.rfile.read(int(self.headers['Content-Length'])) or b'{}') + if self.path.endswith('/embeddings'): + rnd = random.Random(str(req.get('input'))[:200]) + v = [rnd.uniform(-1, 1) for _ in range(1536)] + return self._json({'object': 'list', 'data': [{'object': 'embedding', 'index': 0, 'embedding': v}], 'model': req.get('model'), 'usage': {'prompt_tokens': 1, 'total_tokens': 1}}) + with LOG.open('a') as f: + f.write(json.dumps({'t': time.time(), 'model': req.get('model'), 'stream': req.get('stream'), 'messages': req.get('messages')}, ensure_ascii=False) + '\n') + text = answer_for(req.get('messages') or []) + if not req.get('stream'): + return self._json({'id': 'fake', 'object': 'chat.completion', 'created': int(time.time()), 'model': req.get('model'), + 'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': text}, 'finish_reason': 'stop'}], + 'usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}}) + self.send_response(200); self.send_header('Content-Type', 'text/event-stream'); self.end_headers() + # تکه‌های ۷ نویسه‌ای، مثلِ استریمِ واقعی؛ برچسبِ هم وسطِ تکه‌ها نصف می‌شود + for i in range(0, len(text), 7): + chunk = {'id': 'fake', 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': req.get('model'), + 'choices': [{'index': 0, 'delta': {'content': text[i:i + 7]}, 'finish_reason': None}]} + self.wfile.write(f'data: {json.dumps(chunk, ensure_ascii=False)}\n\n'.encode()); self.wfile.flush(); time.sleep(0.01) + self.wfile.write(b'data: [DONE]\n\n'); self.wfile.flush() + + +if __name__ == '__main__': + LOG.write_text('') + ThreadingHTTPServer(('127.0.0.1', 9911), H).serve_forever()