Files
MathApp/ai_tutor/models.py
Zareian feaa5eb540 Postgres connection pool, clean 503 under overload, time indexes, 4 workers
Postgres here allows 60 connections, but each of the 96 gunicorn threads held its
own persistent one: at 100 concurrent users (local Postgres with the same limit)
21% of requests were 500 "remaining connection slots are reserved". Now each worker
has a psycopg pool (8), so the same load gives 0 errors and 714 req/s (was 460).

- A full pool answers 503 + Retry-After (DRF handler and middleware), not 500.
- The AI tutor and the OTP SMS call return their connection before the network wait.
- statement_timeout 30 s, idle-in-transaction 60 s; bad pool settings and the old
  DB_CONN_MAX_AGE stop startup instead of failing every request.
- /api/health dbPool: per-worker pool stats and workers x pool + 6 vs max_connections.
- Indexes on the columns global counters filter by time (weekly leaderboard,
  global SMS cap, global AI cap); the leaderboard recomputes once per expiry.
- Worker count moves into gunicorn.conf.py (4 on a shared 8-core box).
- security doc: Redis item closed (server uses our own Redis since 2026-10-08).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:21:35 +03:30

111 lines
7.0 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.
from django.db import models
class Conversation(models.Model):
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
lesson_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Lesson
chapter_id = models.IntegerField(null=True, blank=True) # شناسه‌ی فصل RAG (خارج از content/)
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
# metadataِ retrieval برای لایه‌ی RAG (اپ rag): نامِ درس (رشته) و
# شماره‌ی درس. اولین بار از بدنه‌ی POST /api/ai/ask می‌آن و اینجا ذخیره
# می‌شن تا ادامه‌ی همون مکالمه دوباره لازم نباشه فرستاده بشن. نامِ
# collectionِ Qdrant از روی subject + عنوانِ گرید ساخته می‌شه.
subject = models.CharField(max_length=100, blank=True, default='')
lesson_number = models.IntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"conv-{self.pk}-user{self.user_id}"
class Message(models.Model):
ROLE_CHOICES = [('user', 'کاربر'), ('assistant', 'دستیار')]
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages')
role = models.CharField(max_length=10, choices=ROLE_CHOICES)
text = models.TextField()
question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question
# فقط برای پیامِ کاربر: این سوال از کجا پرداخت شد. «free» از سهمیه‌ی روزانه، «credit» از
# اعتبارِ بسته (subscription.QuestionCredit)، «none» هیچ (رد در صافیِ سرور یا جوابِ کش‌شده که
# به API نرسید). سهمیه‌ی رایگانِ امروز = تعدادِ «free»های امروز.
CHARGE_CHOICES = [('free', 'رایگانِ روزانه'), ('credit', 'اعتبارِ بسته'), ('none', 'بی‌هزینه')]
charge = models.CharField(max_length=6, choices=CHARGE_CHOICES, blank=True, default='')
# ایندکس: سقفِ روزانه‌ی کلِ سامانه (_check_global_daily_cap) در هر سوال پیام‌های امروزِ همه را
# می‌شمارد؛ این سریع‌ترین جدولِ در حالِ رشد است.
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
# --- حاشیه‌نویسیِ تولید (فقط برای پیام‌های role='assistant') --------------
# سینیور خواست لایک/دیسلایک بعداً قابلِ تحلیل باشد: «کدام جواب بهتر بوده».
# فیدبکِ خالی (یک بولین) این را نمی‌گوید — باید بدانیم آن جوابِ خاص با کدام
# مدل، کدام نسخه‌ی پرامپت و با چه context ای ساخته شده بود. این فیلدها
# هنگامِ تولید پر می‌شوند، نه هنگامِ فیدبک، تا همه‌ی پاسخ‌ها (حتی آن‌هایی که
# کسی رأی نداده) قابلِ مقایسه بمانند.
model_name = models.CharField(max_length=100, blank=True)
prompt_version = models.CharField(max_length=50, blank=True, db_index=True)
grade_title = models.CharField(max_length=50, blank=True)
retrieval_collection = models.CharField(max_length=200, blank=True)
retrieval_filter_value = models.CharField(max_length=255, blank=True)
chunk_count = models.PositiveSmallIntegerField(default=0)
prompt_tokens = models.PositiveIntegerField(default=0)
completion_tokens = models.PositiveIntegerField(default=0)
latency_ms = models.PositiveIntegerField(default=0)
from_cache = models.BooleanField(default=False)
# شکلِ پاسخ (rag.services.diagram)؛ فقط توصیفِ JSONِ اعتبارسنجی‌شده، نه SVG.
diagram = models.JSONField(null=True, blank=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f"msg-{self.pk}-{self.role}"
class MessageFeedback(models.Model):
message = models.OneToOneField(Message, on_delete=models.CASCADE, related_name='feedback')
is_helpful = models.BooleanField()
# دلیلِ اختیاریِ کاربر. «چرا بد بود» تنها چیزی‌ست که از خودِ عدد درنمی‌آید
# و برای بهترکردنِ پرامپت واقعاً به کار می‌آید.
reason = models.CharField(max_length=300, blank=True)
created_at = models.DateTimeField(auto_now_add=True, null=True)
updated_at = models.DateTimeField(auto_now=True, null=True)
def __str__(self):
return f"feedback-msg{self.message_id}"
class AskIdempotency(models.Model):
"""
⚛️🔒 اصلاح idempotency برای POST /api/ai/ask (همون الگویی که
UserAnswer.idempotency_key/UserLessonCompletion.idempotency_key در
content و Order.idempotency_key در payment دارن): چون نتیجه‌ی هر
«سوال» شاملِ دو ردیفِ Message (کاربر + دستیار) و مصرفِ یک اسلاتِ
کوتای روزانه‌ست (نه یک ردیفِ تکی مثلِ UserAnswer)، این رکورد پل بین
(user, Idempotency-Key) و نتیجه‌ی از قبل ساخته‌شده رو نگه می‌داره تا
رتری/دابل‌کلیکِ همون کلید، دوباره پیام یا کوتای تکراری نسازه و به‌جاش
نتیجه‌ی همون درخواستِ قبلی برگردونده بشه. UniqueConstraint سطح DB روی
(user_id, idempotency_key) هم دفاع در عمق در برابر دو submit هم‌زمانِ
دقیقاً با همون کلیده (مشابه uniq_user_answer_idempotency_key).
"""
user_id = models.IntegerField(db_index=True)
idempotency_key = models.CharField(max_length=100)
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='+')
user_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
assistant_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
# ذخیره می‌شه تا replayِ idempotent بتونه بدونِ فراخوانیِ دوباره‌ی
# generate_tutor_answer همون مقدارِ اصلی رو برگردونه — وقتی provider
# واقعی جایگزینِ stub بشه، هر replay یعنی هزینه/تأخیرِ تکراری و
# follow-upهای متفاوت در هر retry، برخلافِ هدفِ idempotency.
suggested_follow_ups = models.JSONField(default=list)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['user_id', 'idempotency_key'],
name='uniq_ask_idempotency_key',
),
]
def __str__(self):
return f"ask-idem-user{self.user_id}-{self.idempotency_key}"