redraw the figure on follow-ups that only change the numbers; add a local fake OpenAI server

"What if 6 were 4?" has no shape word, so the diagram hint never went next to
it and the tutor would answer without a new figure. When the previous user
question in the conversation had one, a soft hint (redraw only if this
continues that problem) and the same catalog sections now go along.

tools/fake_openai.py stands in for GapGPT locally so the whole tutor path can be
exercised without credit: 12 scripted answers across grades 6-12 went through
streaming, block splitting, repair, storage, history and rendering in the real
chat screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 00:43:04 +03:30
parent 0917ab487f
commit 14a4d9cbf1
5 changed files with 230 additions and 3 deletions

170
tools/fake_openai.py Normal file
View File

@@ -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 و طبقِ همان پرامپتِ دستیار (متنِ مرحله‌به‌مرحله +
بلوکِ <diagram>). هر درخواستِ رسیده در 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<diagram>\n' + json.dumps(obj, ensure_ascii=False) + '\n</diagram>' # 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<diagram>\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</diagram>'),
('۲۵ × ۴', '''۱) 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()
# تکه‌های ۷ نویسه‌ای، مثلِ استریمِ واقعی؛ برچسبِ <diagram> هم وسطِ تکه‌ها نصف می‌شود
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()