Files
MathApp/ai_tutor/models.py
Zareian 58c8e6d014 question packs instead of monthly/yearly plans; hearts are real
- packs (20 / 60 / 150 questions for 45,000 / 119,000 / 259,000 toman) never expire and
  give 7 / 30 / 60 days of unlimited hearts; old subscription plans stay for existing
  subscribers but are no longer on sale
- credit ledger (subscription.QuestionCredit): +N per paid pack (once per order), -1 per
  tutor question that reaches the API; the free daily questions are spent first; questions
  stopped by the server filter, cached answers and failed answers cost nothing
- unlimited hearts from packs live in HeartsPass, so buying a pack never rewrites a
  running subscription
- Message.charge records how each question was paid; GET /api/ai/quota and the ask
  responses report free-left-today and credits
- HEARTS_ENFORCED now defaults to on: zero hearts stops answering until refill or a pack

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:41:39 +03:30

109 lines
6.7 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='')
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)
# شکلِ پاسخ (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}"