Files
MathApp/tools/fake_openai.py
Zareian 14a4d9cbf1 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>
2026-09-29 00:43:04 +03:30

171 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
سرورِ جعلیِ 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()