""" سرورِ جعلیِ 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()