Files
MathApp/ai_tutor/models.py
Zareian 5416fbb2ba real RAG answers on POST /api/ai/ask, plus SSE streaming and answer cache
When RAG is configured the endpoint retrieves lesson-scoped chunks and
generates a grounded answer; otherwise the previous deterministic stub
runs unchanged, so keyless runs and existing tests are unaffected.
Quota / idempotency / user-row lock / clean-503 behaviour is preserved on
every path.

models.py
  - Conversation.subject (CharField) and Conversation.lesson_number
    (IntegerField): retrieval metadata sent on the first ask and reused
    for the rest of the conversation.
migrations/0004_conversation_lesson_number_conversation_subject.py
  - migration for the two new fields.
serializers.py
  - AskRequestSerializer: optional subject / grade / lessonNumber to pin
    the Qdrant collection and lesson/chapter.
  - AskResponseSerializer: usedChunks (retrieved [{title, summary}]) and
    retrievalDebug (collection / filterKey / chapter-or-lesson / counts,
    emitted only under DEBUG).
views.py
  - generate_tutor_answer(): unchanged signature; RAG-configured branch
    calls rag.services generation, stub branch otherwise.
  - AskAIView.post(): resolve subject/grade/lessonNumber (falling back to
    the Conversation), check the answer cache via resolve_target(), run
    retrieve_context + generate_answer on a miss and populate the cache,
    return usedChunks (+ retrievalDebug under DEBUG). Failures still
    release the reserved quota slot and surface as 503.
  - AskAIView._streaming_response(): opt-in "stream": true returns a
    text/event-stream of chunk / done / error events; same retrieval,
    phase-3 persist, idempotency and quota accounting as the JSON path,
    and it consults the answer cache first. Non-stream JSON contract is
    untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:30:33 +03:30

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