Initial commit - clean project structure
This commit is contained in:
0
ai_tutor/__init__.py
Normal file
0
ai_tutor/__init__.py
Normal file
25
ai_tutor/admin.py
Normal file
25
ai_tutor/admin.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Conversation, Message, MessageFeedback
|
||||
|
||||
|
||||
class MessageInline(admin.TabularInline):
|
||||
model = Message
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Conversation)
|
||||
class ConversationAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'user_id', 'lesson_id', 'grade_id', 'created_at')
|
||||
inlines = [MessageInline]
|
||||
|
||||
|
||||
@admin.register(Message)
|
||||
class MessageAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'conversation', 'role', 'created_at')
|
||||
list_filter = ('role',)
|
||||
|
||||
|
||||
@admin.register(MessageFeedback)
|
||||
class MessageFeedbackAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'message', 'is_helpful')
|
||||
6
ai_tutor/apps.py
Normal file
6
ai_tutor/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AiTutorConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'ai_tutor'
|
||||
48
ai_tutor/migrations/0001_initial.py
Normal file
48
ai_tutor/migrations/0001_initial.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-10 11:37
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Conversation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('user_id', models.IntegerField(db_index=True)),
|
||||
('lesson_id', models.IntegerField(blank=True, null=True)),
|
||||
('chapter_id', models.IntegerField(blank=True, null=True)),
|
||||
('grade_id', models.IntegerField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Message',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('user', 'کاربر'), ('assistant', 'دستیار')], max_length=10)),
|
||||
('text', models.TextField()),
|
||||
('question_id', models.IntegerField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='ai_tutor.conversation')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MessageFeedback',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_helpful', models.BooleanField()),
|
||||
('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feedback', to='ai_tutor.message')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
ai_tutor/migrations/__init__.py
Normal file
0
ai_tutor/migrations/__init__.py
Normal file
36
ai_tutor/models.py
Normal file
36
ai_tutor/models.py
Normal file
@@ -0,0 +1,36 @@
|
||||
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
|
||||
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}"
|
||||
39
ai_tutor/serializers.py
Normal file
39
ai_tutor/serializers.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class AskRequestSerializer(serializers.Serializer):
|
||||
conversationId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
lessonId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
chapterId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
gradeId = serializers.IntegerField(required=False, allow_null=True)
|
||||
questionId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
userQuestion = serializers.CharField()
|
||||
stream = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
class AskResponseSerializer(serializers.Serializer):
|
||||
conversationId = serializers.CharField()
|
||||
messageId = serializers.CharField()
|
||||
answer = serializers.CharField()
|
||||
suggestedFollowUps = serializers.ListField(child=serializers.CharField())
|
||||
remainingQuota = serializers.IntegerField()
|
||||
|
||||
|
||||
class MessageSerializer(serializers.Serializer):
|
||||
messageId = serializers.CharField()
|
||||
role = serializers.CharField()
|
||||
text = serializers.CharField()
|
||||
|
||||
|
||||
class ConversationMessagesResponseSerializer(serializers.Serializer):
|
||||
conversationId = serializers.CharField()
|
||||
lessonId = serializers.CharField(allow_null=True)
|
||||
messages = MessageSerializer(many=True)
|
||||
|
||||
|
||||
class FeedbackRequestSerializer(serializers.Serializer):
|
||||
isHelpful = serializers.BooleanField()
|
||||
|
||||
|
||||
class FeedbackResponseSerializer(serializers.Serializer):
|
||||
success = serializers.BooleanField()
|
||||
3
ai_tutor/tests.py
Normal file
3
ai_tutor/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
9
ai_tutor/urls.py
Normal file
9
ai_tutor/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import AskAIView, ConversationMessagesView, MessageFeedbackView
|
||||
|
||||
urlpatterns = [
|
||||
path('ai/ask', AskAIView.as_view(), name='ai-ask'),
|
||||
path('ai/conversations/<int:conversation_id>/messages', ConversationMessagesView.as_view(), name='ai-conversation-messages'),
|
||||
path('ai/messages/<int:message_id>/feedback', MessageFeedbackView.as_view(), name='ai-message-feedback'),
|
||||
]
|
||||
147
ai_tutor/views.py
Normal file
147
ai_tutor/views.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.exceptions import Throttled
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from accounts.models import User as AccountUser
|
||||
from .models import Conversation, Message, MessageFeedback
|
||||
from .serializers import (
|
||||
AskRequestSerializer,
|
||||
AskResponseSerializer,
|
||||
ConversationMessagesResponseSerializer,
|
||||
FeedbackRequestSerializer,
|
||||
FeedbackResponseSerializer,
|
||||
)
|
||||
|
||||
DAILY_AI_QUOTA = 15
|
||||
|
||||
|
||||
def generate_tutor_answer(user_question: str, grade_id, lesson_id) -> tuple[str, list[str]]:
|
||||
"""
|
||||
استاب پاسخ AI Tutor. اینجا محل اتصال به مدل واقعی (مثلاً Claude API از طریق
|
||||
Anthropic SDK) هست؛ فعلاً برای اینکه بدون نیاز به کلید API هم بشه کل جریان
|
||||
رو با Swagger تست کرد، یک پاسخ ساده و قطعی برمیگردونه.
|
||||
"""
|
||||
answer = (
|
||||
f'این یک پاسخ نمونه (stub) برای سوال «{user_question}» است. '
|
||||
'برای اتصال به مدل واقعی هوش مصنوعی، این تابع را در ai_tutor/views.py '
|
||||
'جایگزین کنید (مثلاً با فراخوانی Anthropic API).'
|
||||
)
|
||||
follow_ups = ['یک مثال دیگر بزن', 'بیشتر توضیح بده']
|
||||
return answer, follow_ups
|
||||
|
||||
|
||||
class AskAIView(APIView):
|
||||
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(request=AskRequestSerializer, responses=AskResponseSerializer, tags=['AI Tutor (ai_tutor)'])
|
||||
def post(self, request):
|
||||
serializer = AskRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
v = serializer.validated_data
|
||||
user = request.user
|
||||
|
||||
# ⚠️ اصلاح: چک و مصرفِ کوتای روزانه قبلاً race condition داشت —
|
||||
# دو درخواست همزمان از همون کاربر میتونستن هر دو شمارشِ «امروز»
|
||||
# رو قبل از ساختن پیامِ خودشون بخونن و هر دو رد بشن، در نتیجه از
|
||||
# سقف ۱۵ سوال روزانه عبور کنن. حالا با قفلکردن ردیف کاربر
|
||||
# (select_for_update) در یک تراکنش اتمیک، چک و مصرفِ کوتا برای هر
|
||||
# کاربر serialize میشه؛ درخواستهای همزمانِ همون کاربر منتظر آزاد
|
||||
# شدن قفل میمونن و دیگه نمیتونن همزمان از کنار چک رد بشن.
|
||||
with transaction.atomic():
|
||||
AccountUser.objects.select_for_update().get(pk=user.id)
|
||||
|
||||
today = timezone.localdate()
|
||||
used_today = Message.objects.filter(
|
||||
conversation__user_id=user.id, role='user', created_at__date=today
|
||||
).count()
|
||||
if used_today >= DAILY_AI_QUOTA:
|
||||
raise Throttled(detail='سقف روزانهی سوال از هوش مصنوعی به پایان رسیده است.')
|
||||
|
||||
conversation_id = v.get('conversationId')
|
||||
if conversation_id:
|
||||
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=user.id)
|
||||
else:
|
||||
conversation = Conversation.objects.create(
|
||||
user_id=user.id,
|
||||
lesson_id=v.get('lessonId') or None,
|
||||
chapter_id=v.get('chapterId') or None,
|
||||
grade_id=v.get('gradeId'),
|
||||
)
|
||||
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
role='user',
|
||||
text=v['userQuestion'],
|
||||
question_id=v.get('questionId') or None,
|
||||
)
|
||||
|
||||
answer_text, follow_ups = generate_tutor_answer(
|
||||
v['userQuestion'], v.get('gradeId'), v.get('lessonId')
|
||||
)
|
||||
assistant_message = Message.objects.create(
|
||||
conversation=conversation,
|
||||
role='assistant',
|
||||
text=answer_text,
|
||||
question_id=v.get('questionId') or None,
|
||||
)
|
||||
|
||||
used_today += 1 # همین سوالی که الان ثبت شد هم جزو مصرف امروزه
|
||||
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0)
|
||||
|
||||
data = {
|
||||
'conversationId': str(conversation.id),
|
||||
'messageId': str(assistant_message.id),
|
||||
'answer': answer_text,
|
||||
'suggestedFollowUps': follow_ups,
|
||||
'remainingQuota': remaining_quota,
|
||||
}
|
||||
return Response(AskResponseSerializer(data).data)
|
||||
|
||||
|
||||
class ConversationMessagesView(APIView):
|
||||
"""GET /api/ai/conversations/{id}/messages — تاریخچهی مکالمه"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)'])
|
||||
def get(self, request, conversation_id):
|
||||
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=request.user.id)
|
||||
messages = conversation.messages.all()
|
||||
|
||||
data = {
|
||||
'conversationId': str(conversation.id),
|
||||
'lessonId': str(conversation.lesson_id) if conversation.lesson_id else None,
|
||||
'messages': [
|
||||
{'messageId': str(m.id), 'role': m.role, 'text': m.text} for m in messages
|
||||
],
|
||||
}
|
||||
return Response(ConversationMessagesResponseSerializer(data).data)
|
||||
|
||||
|
||||
class MessageFeedbackView(APIView):
|
||||
"""POST /api/ai/messages/{id}/feedback — فیدبک 👍/👎"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
request=FeedbackRequestSerializer,
|
||||
responses=FeedbackResponseSerializer,
|
||||
tags=['AI Tutor (ai_tutor)'],
|
||||
)
|
||||
def post(self, request, message_id):
|
||||
message = get_object_or_404(Message, pk=message_id, conversation__user_id=request.user.id)
|
||||
serializer = FeedbackRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
MessageFeedback.objects.update_or_create(
|
||||
message=message,
|
||||
defaults={'is_helpful': serializer.validated_data['isHelpful']},
|
||||
)
|
||||
return Response(FeedbackResponseSerializer({'success': True}).data)
|
||||
Reference in New Issue
Block a user