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 created_at = models.DateTimeField(auto_now_add=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) 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}"