Files
MathApp/ai_tutor/models.py
Zareian fa56308a35 make the tutor grade-aware and its answers measurable
مهم‌ترین خواسته‌ی سینیور این بود که بسنجیم پاسخِ AI واقعاً یاد می‌دهد یا نه.
برای سنجیدن، اول باید پاسخ‌ها قابلِ ردیابی می‌شدند.

پرامپت‌ها پایه‌آگاه شدند: تا پیش از این «معلمِ دبستانی» برای پایه نهم هاردکد
بود، هم در تولیدِ پاسخ و هم در vision — یعنی دانش‌آموزِ نهم لحنِ ابتدایی
می‌گرفت. PROMPT_VERSION حالا روی هر پیام ذخیره می‌شود.

هر پاسخ حاشیه‌نویسی می‌شود: مدل، نسخه‌ی پرامپت، پایه، collection، مقدارِ
فیلتر، تعداد chunk، توکن، تأخیر و from_cache. فیدبک reason اختیاری گرفت تا
لایک/دیسلایک بعداً قابلِ تحلیل باشد («بشه چک کرد کدوم بهتر بوده»).

ai_feedback_report --by prompt_version نسبتِ رضایت را کنارِ مصرف/هزینه
می‌گذارد. rag_eval سوال‌های واقعی را از مسیرِ واقعی می‌پرسد، با
--retrieval-only برای اجرای ارزان.

rag/services/lesson_context.py از lessonId تنها پایه و فصل را درمی‌آورد؛
normalize_grade_title هم اختلافِ «پایه نهم» در دیتابیس با «ریاضی نهم» در
نامِ collection را می‌بندد — بدونِ آن مسیرِ gradeId بی‌سروصدا خالی برمی‌گشت.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:00:40 +03:30

102 lines
6.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
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}"