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()