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>
This commit is contained in:
0
ai_tutor/management/__init__.py
Normal file
0
ai_tutor/management/__init__.py
Normal file
0
ai_tutor/management/commands/__init__.py
Normal file
0
ai_tutor/management/commands/__init__.py
Normal file
135
ai_tutor/management/commands/ai_feedback_report.py
Normal file
135
ai_tutor/management/commands/ai_feedback_report.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
گزارشِ کیفیت و مصرفِ دستیارِ هوش مصنوعی.
|
||||||
|
|
||||||
|
سینیور دو چیز خواست و هر دو از همین یک جدول درمیآیند:
|
||||||
|
«یک ساینتیشن برای لایک/دیسلایک بگیریم که بعداً بشود چک کرد کدام بهتر بوده»
|
||||||
|
«اگر لیمیت خورد یا تمام شد بهم بگید که شارژش کنیم»
|
||||||
|
|
||||||
|
نمونه:
|
||||||
|
python manage.py ai_feedback_report
|
||||||
|
python manage.py ai_feedback_report --days 7 --by grade_title
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db.models import Avg, Count, Q, Sum
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from ai_tutor.models import Message
|
||||||
|
|
||||||
|
# قیمتِ تقریبیِ هر یک میلیون توکن (دلار) برای برآوردِ هزینه. عدد دقیق نیست و
|
||||||
|
# فقط برای «چقدر مانده / کِی باید شارژ کنیم» است.
|
||||||
|
_PRICE_PER_MTOK = {
|
||||||
|
'gpt-4o-mini': (0.15, 0.60),
|
||||||
|
'gpt-4o': (2.50, 10.00),
|
||||||
|
}
|
||||||
|
_GROUPABLE = ['prompt_version', 'model_name', 'grade_title', 'retrieval_collection']
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'نسبتِ 👍/👎 و مصرفِ توکنِ پاسخهای دستیار، به تفکیکِ نسخهی پرامپت/مدل/پایه.'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--days', type=int, default=30)
|
||||||
|
parser.add_argument('--by', choices=_GROUPABLE, default='prompt_version')
|
||||||
|
|
||||||
|
def handle(self, *args, **opts):
|
||||||
|
since = timezone.now() - timedelta(days=opts['days'])
|
||||||
|
field = opts['by']
|
||||||
|
|
||||||
|
qs = Message.objects.filter(role='assistant', created_at__gte=since)
|
||||||
|
total = qs.count()
|
||||||
|
if not total:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
f'در {opts["days"]} روزِ گذشته هیچ پاسخِ دستیاری ثبت نشده است.'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
qs.values(field)
|
||||||
|
.annotate(
|
||||||
|
answers=Count('id'),
|
||||||
|
liked=Count('id', filter=Q(feedback__is_helpful=True)),
|
||||||
|
disliked=Count('id', filter=Q(feedback__is_helpful=False)),
|
||||||
|
with_context=Count('id', filter=Q(chunk_count__gt=0)),
|
||||||
|
avg_latency=Avg('latency_ms'),
|
||||||
|
tok_in=Sum('prompt_tokens'),
|
||||||
|
tok_out=Sum('completion_tokens'),
|
||||||
|
)
|
||||||
|
.order_by('-answers')
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING(
|
||||||
|
f'\n{opts["days"]} روزِ گذشته — {total} پاسخِ دستیار، گروهبندی بر اساسِ {field}\n'
|
||||||
|
))
|
||||||
|
header = f'{field:<28} {"پاسخ":>6} {"👍":>5} {"👎":>5} {"رضایت":>8} {"با منبع":>9} {"میانگین ms":>11}'
|
||||||
|
self.stdout.write(header)
|
||||||
|
self.stdout.write('─' * len(header))
|
||||||
|
for r in rows:
|
||||||
|
rated = r['liked'] + r['disliked']
|
||||||
|
ratio = f"{100 * r['liked'] / rated:.0f}%" if rated else '—'
|
||||||
|
ctx = f"{100 * r['with_context'] / r['answers']:.0f}%"
|
||||||
|
label = (r[field] or '(خالی)')[:27]
|
||||||
|
self.stdout.write(
|
||||||
|
f"{label:<28} {r['answers']:>6} {r['liked']:>5} {r['disliked']:>5} "
|
||||||
|
f"{ratio:>8} {ctx:>9} {(r['avg_latency'] or 0):>11.0f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
rated_total = qs.filter(feedback__isnull=False).count()
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write(
|
||||||
|
f'رأی دادهشده: {rated_total} از {total} پاسخ '
|
||||||
|
f'({100 * rated_total / total:.0f}%).'
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- مصرف و هزینه ---------------------------------------------------
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING('\nمصرفِ توکن (برای پیگیریِ شارژ)\n'))
|
||||||
|
# ⚠️ پاسخِ کششده توکنِ فراخوانیِ اصلی را کپی میکند و هیچ هزینهای
|
||||||
|
# نداشته؛ اگر بشماریمش، گزارشی که کارش تصمیمگیری دربارهی شارژ است
|
||||||
|
# به اندازهی نرخِ اصابتِ کش هزینه را بیشتر نشان میدهد.
|
||||||
|
usage = (
|
||||||
|
qs.exclude(model_name='').filter(from_cache=False)
|
||||||
|
.values('model_name')
|
||||||
|
.annotate(tok_in=Sum('prompt_tokens'), tok_out=Sum('completion_tokens'), n=Count('id'))
|
||||||
|
.order_by('-n')
|
||||||
|
)
|
||||||
|
grand = 0.0
|
||||||
|
for u in usage:
|
||||||
|
tin, tout = u['tok_in'] or 0, u['tok_out'] or 0
|
||||||
|
price = _PRICE_PER_MTOK.get(u['model_name'])
|
||||||
|
if price:
|
||||||
|
cost = tin / 1e6 * price[0] + tout / 1e6 * price[1]
|
||||||
|
grand += cost
|
||||||
|
cost_text = f'≈ ${cost:.2f}'
|
||||||
|
else:
|
||||||
|
cost_text = 'قیمتِ این مدل در جدول نیست'
|
||||||
|
self.stdout.write(
|
||||||
|
f" {u['model_name']:<20} {u['n']:>5} پاسخ ورودی {tin:>9,} خروجی {tout:>9,} {cost_text}"
|
||||||
|
)
|
||||||
|
if grand:
|
||||||
|
self.stdout.write(self.style.WARNING(f'\n جمعِ تقریبی: ${grand:.2f}'))
|
||||||
|
cached = qs.filter(from_cache=True).count()
|
||||||
|
self.stdout.write(self.style.HTTP_INFO(
|
||||||
|
' توجه: پاسخهای استریمی usage برنمیگردانند و توکنشان صفر شمرده میشود، '
|
||||||
|
'پس عددِ واقعی از این بیشتر است.'
|
||||||
|
))
|
||||||
|
if cached:
|
||||||
|
self.stdout.write(self.style.HTTP_INFO(
|
||||||
|
f' {cached} پاسخ از کش آمده و در هزینه شمرده نشده (فراخوانیِ تازهای نداشته).'
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- بدترین پاسخها ---------------------------------------------------
|
||||||
|
worst = (
|
||||||
|
qs.filter(feedback__is_helpful=False)
|
||||||
|
.select_related('feedback')
|
||||||
|
.order_by('-created_at')[:10]
|
||||||
|
)
|
||||||
|
if worst:
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING('\nآخرین پاسخهای 👎 (برای بازبینیِ دستی)\n'))
|
||||||
|
for m in worst:
|
||||||
|
reason = (m.feedback.reason or '').strip()
|
||||||
|
self.stdout.write(
|
||||||
|
f" #{m.id} [{m.prompt_version or '—'} / {m.grade_title or '—'} / "
|
||||||
|
f"chunk={m.chunk_count}] {m.text[:70]}…"
|
||||||
|
+ (f'\n دلیلِ کاربر: {reason}' if reason else '')
|
||||||
|
)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-09-12 14:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ai_tutor', '0004_conversation_lesson_number_conversation_subject'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='chunk_count',
|
||||||
|
field=models.PositiveSmallIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='completion_tokens',
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='from_cache',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='grade_title',
|
||||||
|
field=models.CharField(blank=True, max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='latency_ms',
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='model_name',
|
||||||
|
field=models.CharField(blank=True, max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='prompt_tokens',
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='prompt_version',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='retrieval_collection',
|
||||||
|
field=models.CharField(blank=True, max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='retrieval_filter_value',
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='messagefeedback',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='messagefeedback',
|
||||||
|
name='reason',
|
||||||
|
field=models.CharField(blank=True, max_length=300),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='messagefeedback',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -27,6 +27,23 @@ class Message(models.Model):
|
|||||||
question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question
|
question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
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:
|
class Meta:
|
||||||
ordering = ['created_at']
|
ordering = ['created_at']
|
||||||
|
|
||||||
@@ -37,6 +54,11 @@ class Message(models.Model):
|
|||||||
class MessageFeedback(models.Model):
|
class MessageFeedback(models.Model):
|
||||||
message = models.OneToOneField(Message, on_delete=models.CASCADE, related_name='feedback')
|
message = models.OneToOneField(Message, on_delete=models.CASCADE, related_name='feedback')
|
||||||
is_helpful = models.BooleanField()
|
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):
|
def __str__(self):
|
||||||
return f"feedback-msg{self.message_id}"
|
return f"feedback-msg{self.message_id}"
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ class ConversationMessagesResponseSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class FeedbackRequestSerializer(serializers.Serializer):
|
class FeedbackRequestSerializer(serializers.Serializer):
|
||||||
isHelpful = serializers.BooleanField()
|
isHelpful = serializers.BooleanField()
|
||||||
|
# اختیاری: «چرا». تنها چیزی که از خودِ 👍/👎 درنمیآید و برای اصلاحِ
|
||||||
|
# پرامپت واقعاً به کار میآید.
|
||||||
|
reason = serializers.CharField(max_length=300, required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
class FeedbackResponseSerializer(serializers.Serializer):
|
class FeedbackResponseSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
@@ -29,6 +30,32 @@ logger = logging.getLogger(__name__)
|
|||||||
DAILY_AI_QUOTA = 15
|
DAILY_AI_QUOTA = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _annotation(retrieval_meta: dict, generation_meta: dict, *, from_cache: bool) -> dict:
|
||||||
|
"""
|
||||||
|
متادیتای بازیابی/تولید → فیلدهای Message.
|
||||||
|
|
||||||
|
همهی مقادیر به str/int امن تبدیل میشوند و هر کلیدِ غایب مقدارِ پیشفرض
|
||||||
|
میگیرد: این متادیتا از cache هم میآید و cacheهای قدیمی (که پیش از افزودنِ
|
||||||
|
این فیلدها ست شدهاند) کلیدها را ندارند — نبودشان نباید ثبتِ پیام را
|
||||||
|
بشکند، چون آنوقت خودِ پاسخِ کاربر هم از بین میرود.
|
||||||
|
"""
|
||||||
|
retrieval_meta = retrieval_meta or {}
|
||||||
|
generation_meta = generation_meta or {}
|
||||||
|
filter_value = retrieval_meta.get('filterValue')
|
||||||
|
return {
|
||||||
|
'model_name': str(generation_meta.get('model') or '')[:100],
|
||||||
|
'prompt_version': str(generation_meta.get('promptVersion') or '')[:50],
|
||||||
|
'grade_title': str(retrieval_meta.get('gradeTitle') or '')[:50],
|
||||||
|
'retrieval_collection': str(retrieval_meta.get('collection') or '')[:200],
|
||||||
|
'retrieval_filter_value': ('' if filter_value is None else str(filter_value))[:255],
|
||||||
|
'chunk_count': int(retrieval_meta.get('chunkCount') or 0),
|
||||||
|
'prompt_tokens': int(generation_meta.get('promptTokens') or 0),
|
||||||
|
'completion_tokens': int(generation_meta.get('completionTokens') or 0),
|
||||||
|
'latency_ms': int(generation_meta.get('latencyMs') or 0),
|
||||||
|
'from_cache': bool(from_cache),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class GuestAwareAIThrottle(ScopedRateThrottle):
|
class GuestAwareAIThrottle(ScopedRateThrottle):
|
||||||
"""
|
"""
|
||||||
⚠️ اضافه شده: ScopedRateThrottle استاندارد برای کاربرِ احرازهویتشده
|
⚠️ اضافه شده: ScopedRateThrottle استاندارد برای کاربرِ احرازهویتشده
|
||||||
@@ -121,7 +148,10 @@ def generate_tutor_answer(
|
|||||||
context_chunks = context if isinstance(context, list) else None
|
context_chunks = context if isinstance(context, list) else None
|
||||||
# استثناهای شبکه/provider اینجا خفه نمیشن؛ AskAIView اونها رو به
|
# استثناهای شبکه/provider اینجا خفه نمیشن؛ AskAIView اونها رو به
|
||||||
# 503 AIProviderUnavailable تبدیل میکنه (و اسلاتِ رزروشده رو آزاد).
|
# 503 AIProviderUnavailable تبدیل میکنه (و اسلاتِ رزروشده رو آزاد).
|
||||||
return generate_answer(user_question, context_chunks)
|
# این تابع قراردادِ دوتاییاش را نگه میدارد؛ متادیتای تولید فقط در
|
||||||
|
# مسیرِ اصلیِ AskAIView (که آن را ذخیره میکند) لازم است.
|
||||||
|
answer, follow_ups, _meta = generate_answer(user_question, context_chunks)
|
||||||
|
return answer, follow_ups
|
||||||
|
|
||||||
context_note = f' (با context: {context})' if context else ''
|
context_note = f' (با context: {context})' if context else ''
|
||||||
answer = (
|
answer = (
|
||||||
@@ -223,14 +253,40 @@ class AskAIView(APIView):
|
|||||||
# اگر RAG پیکربندی نشده باشد context=None میماند و generate_tutor_answer
|
# اگر RAG پیکربندی نشده باشد context=None میماند و generate_tutor_answer
|
||||||
# همان پاسخِ stub را میدهد. خطای شبکه/provider در همین try به 503
|
# همان پاسخِ stub را میدهد. خطای شبکه/provider در همین try به 503
|
||||||
# تبدیل و اسلاتِ رزروشده آزاد میشود.
|
# تبدیل و اسلاتِ رزروشده آزاد میشود.
|
||||||
grade_id = v.get('gradeId')
|
# مقادیرِ ذخیرهشده روی مکالمه هم fallbackاند: در پیامِ دوم به بعد، اپ
|
||||||
lesson_id = v.get('lessonId')
|
# فقط conversationId میفرستد و دیگر lessonId/gradeId نمیآید.
|
||||||
|
grade_id = v.get('gradeId') or conversation.grade_id
|
||||||
|
lesson_id = v.get('lessonId') or conversation.lesson_id
|
||||||
subject = (v.get('subject') or conversation.subject or '').strip()
|
subject = (v.get('subject') or conversation.subject or '').strip()
|
||||||
grade_title = (v.get('grade') or '').strip()
|
grade_title = (v.get('grade') or '').strip()
|
||||||
lesson_number = v.get('lessonNumber')
|
lesson_number = v.get('lessonNumber')
|
||||||
if lesson_number is None:
|
if lesson_number is None:
|
||||||
lesson_number = conversation.lesson_number
|
lesson_number = conversation.lesson_number
|
||||||
|
|
||||||
|
# اگر اپ فقط lessonId (یا فقط conversationId) داده — حالتِ طبیعی، چون
|
||||||
|
# میداند دانشآموز روی کدام درس است نه اینکه نامِ collectionِ Qdrant
|
||||||
|
# چیست — خودمان subject/پایه/فصل را از زنجیرهی
|
||||||
|
# Lesson→Node→Unit→Grade درمیآوریم.
|
||||||
|
#
|
||||||
|
# ⚠️ شرط عمداً `grade_title` را هم میبیند: پیشتر فقط subject و
|
||||||
|
# lesson_number را چک میکرد، و چون Conversation فیلدی برای عنوانِ پایه
|
||||||
|
# ندارد، از پیامِ دومِ هر مکالمه grade_title خالی میماند و
|
||||||
|
# resolve_target هیچ collectionی نمیساخت — یعنی فقط جوابِ اول به کتاب
|
||||||
|
# مستند بود و بقیه بیسروصدا عمومی میشدند.
|
||||||
|
if lesson_id and not (subject and grade_title and lesson_number is not None):
|
||||||
|
from rag.services.lesson_context import resolve_from_lesson
|
||||||
|
|
||||||
|
derived = resolve_from_lesson(lesson_id)
|
||||||
|
subject = subject or derived.get('subject', '')
|
||||||
|
grade_title = grade_title or derived.get('gradeTitle', '')
|
||||||
|
if lesson_number is None:
|
||||||
|
lesson_number = derived.get('chapter')
|
||||||
|
# تا آخرِ همین مکالمه دوباره حساب نشود.
|
||||||
|
if subject and not conversation.subject:
|
||||||
|
conversation.subject = subject
|
||||||
|
conversation.lesson_number = lesson_number
|
||||||
|
conversation.save(update_fields=['subject', 'lesson_number'])
|
||||||
|
|
||||||
# --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندیشده.
|
# --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندیشده.
|
||||||
# قراردادِ JSONِ غیراستریم دستنخورده میماند؛ Swagger که stream
|
# قراردادِ JSONِ غیراستریم دستنخورده میماند؛ Swagger که stream
|
||||||
# نمیفرسته همون مسیرِ قبلی رو میگیره.
|
# نمیفرسته همون مسیرِ قبلی رو میگیره.
|
||||||
@@ -248,6 +304,8 @@ class AskAIView(APIView):
|
|||||||
|
|
||||||
used_chunks: list[dict] = []
|
used_chunks: list[dict] = []
|
||||||
retrieval_meta: dict = {}
|
retrieval_meta: dict = {}
|
||||||
|
generation_meta: dict = {}
|
||||||
|
answer_from_cache = False
|
||||||
context = None
|
context = None
|
||||||
try:
|
try:
|
||||||
from rag.services.config import rag_is_configured
|
from rag.services.config import rag_is_configured
|
||||||
@@ -258,13 +316,15 @@ class AskAIView(APIView):
|
|||||||
from rag.services.retrieval import resolve_target, retrieve_context
|
from rag.services.retrieval import resolve_target, retrieve_context
|
||||||
|
|
||||||
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
|
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
|
||||||
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'])
|
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
|
||||||
cached = answer_cache.get(cache_key)
|
cached = answer_cache.get(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
answer_text = cached['answer']
|
answer_text = cached['answer']
|
||||||
follow_ups = list(cached['followUps'])
|
follow_ups = list(cached['followUps'])
|
||||||
used_chunks = cached['usedChunks']
|
used_chunks = cached['usedChunks']
|
||||||
retrieval_meta = cached.get('retrievalMeta') or {}
|
retrieval_meta = cached.get('retrievalMeta') or {}
|
||||||
|
generation_meta = cached.get('generationMeta') or {}
|
||||||
|
answer_from_cache = True
|
||||||
else:
|
else:
|
||||||
used_chunks, retrieval_meta = retrieve_context(
|
used_chunks, retrieval_meta = retrieve_context(
|
||||||
v['userQuestion'], subject=subject, grade_id=grade_id,
|
v['userQuestion'], subject=subject, grade_id=grade_id,
|
||||||
@@ -274,10 +334,14 @@ class AskAIView(APIView):
|
|||||||
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||||||
for c in used_chunks
|
for c in used_chunks
|
||||||
]
|
]
|
||||||
answer_text, follow_ups = generate_answer(v['userQuestion'], used_chunks or None)
|
answer_text, follow_ups, generation_meta = generate_answer(
|
||||||
|
v['userQuestion'], used_chunks or None,
|
||||||
|
retrieval_meta.get('gradeTitle') or grade_title or '',
|
||||||
|
)
|
||||||
answer_cache.set(cache_key, {
|
answer_cache.set(cache_key, {
|
||||||
'answer': answer_text, 'followUps': follow_ups,
|
'answer': answer_text, 'followUps': follow_ups,
|
||||||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||||
|
'generationMeta': generation_meta,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
answer_text, follow_ups = generate_tutor_answer(
|
answer_text, follow_ups = generate_tutor_answer(
|
||||||
@@ -295,6 +359,7 @@ class AskAIView(APIView):
|
|||||||
role='assistant',
|
role='assistant',
|
||||||
text=answer_text,
|
text=answer_text,
|
||||||
question_id=v.get('questionId') or None,
|
question_id=v.get('questionId') or None,
|
||||||
|
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
||||||
)
|
)
|
||||||
|
|
||||||
if idempotency_key:
|
if idempotency_key:
|
||||||
@@ -356,7 +421,7 @@ class AskAIView(APIView):
|
|||||||
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
|
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
|
||||||
"""
|
"""
|
||||||
from rag.services import answer_cache
|
from rag.services import answer_cache
|
||||||
from rag.services.generation import default_follow_ups, stream_answer
|
from rag.services.generation import PROMPT_VERSION, default_follow_ups, stream_answer
|
||||||
from rag.services.retrieval import resolve_target, retrieve_context
|
from rag.services.retrieval import resolve_target, retrieve_context
|
||||||
|
|
||||||
def _sse(event: str, payload: dict) -> str:
|
def _sse(event: str, payload: dict) -> str:
|
||||||
@@ -364,8 +429,10 @@ class AskAIView(APIView):
|
|||||||
|
|
||||||
def _gen():
|
def _gen():
|
||||||
nonlocal used_today
|
nonlocal used_today
|
||||||
|
generation_meta: dict = {}
|
||||||
|
answer_from_cache = False
|
||||||
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
|
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
|
||||||
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'])
|
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
|
||||||
try:
|
try:
|
||||||
cached = answer_cache.get(cache_key)
|
cached = answer_cache.get(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
@@ -373,6 +440,8 @@ class AskAIView(APIView):
|
|||||||
follow_ups = list(cached['followUps'])
|
follow_ups = list(cached['followUps'])
|
||||||
used_chunks = cached['usedChunks']
|
used_chunks = cached['usedChunks']
|
||||||
retrieval_meta = cached.get('retrievalMeta') or {}
|
retrieval_meta = cached.get('retrievalMeta') or {}
|
||||||
|
generation_meta = cached.get('generationMeta') or {}
|
||||||
|
answer_from_cache = True
|
||||||
yield _sse('chunk', {'delta': answer_text})
|
yield _sse('chunk', {'delta': answer_text})
|
||||||
else:
|
else:
|
||||||
used_chunks, retrieval_meta = retrieve_context(
|
used_chunks, retrieval_meta = retrieve_context(
|
||||||
@@ -384,14 +453,28 @@ class AskAIView(APIView):
|
|||||||
for c in used_chunks
|
for c in used_chunks
|
||||||
]
|
]
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for delta in stream_answer(v['userQuestion'], used_chunks or None):
|
started = time.monotonic()
|
||||||
|
for delta in stream_answer(
|
||||||
|
v['userQuestion'], used_chunks or None,
|
||||||
|
retrieval_meta.get('gradeTitle') or grade_title or '',
|
||||||
|
):
|
||||||
parts.append(delta)
|
parts.append(delta)
|
||||||
yield _sse('chunk', {'delta': delta})
|
yield _sse('chunk', {'delta': delta})
|
||||||
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
|
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
|
||||||
follow_ups = default_follow_ups()
|
follow_ups = default_follow_ups()
|
||||||
|
# مسیرِ استریم usage برنمیگرداند (پاسخ توکنبهتوکن است)،
|
||||||
|
# پس فقط چیزی را ثبت میکنیم که واقعاً میدانیم؛ توکنها صفر
|
||||||
|
# میمانند و گزارشِ هزینه هم همین را نشان میدهد.
|
||||||
|
generation_meta = {
|
||||||
|
'model': settings.OPENAI_CHAT_MODEL,
|
||||||
|
'promptVersion': PROMPT_VERSION,
|
||||||
|
'latencyMs': int((time.monotonic() - started) * 1000),
|
||||||
|
'hadContext': bool(used_chunks),
|
||||||
|
}
|
||||||
answer_cache.set(cache_key, {
|
answer_cache.set(cache_key, {
|
||||||
'answer': answer_text, 'followUps': follow_ups,
|
'answer': answer_text, 'followUps': follow_ups,
|
||||||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||||
|
'generationMeta': generation_meta,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('stream generate failed for user_id=%s', user.id)
|
logger.exception('stream generate failed for user_id=%s', user.id)
|
||||||
@@ -405,6 +488,7 @@ class AskAIView(APIView):
|
|||||||
assistant_message = Message.objects.create(
|
assistant_message = Message.objects.create(
|
||||||
conversation=conversation, role='assistant', text=answer_text,
|
conversation=conversation, role='assistant', text=answer_text,
|
||||||
question_id=v.get('questionId') or None,
|
question_id=v.get('questionId') or None,
|
||||||
|
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
||||||
)
|
)
|
||||||
if idempotency_key:
|
if idempotency_key:
|
||||||
AskIdempotency.objects.create(
|
AskIdempotency.objects.create(
|
||||||
@@ -540,8 +624,11 @@ class MessageFeedbackView(APIView):
|
|||||||
serializer = FeedbackRequestSerializer(data=request.data)
|
serializer = FeedbackRequestSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
MessageFeedback.objects.update_or_create(
|
# `reason` فقط وقتی نوشته میشود که در همین درخواست آمده باشد: کاربری
|
||||||
message=message,
|
# که رأیش را عوض میکند معمولاً دلیل نمینویسد، و پیشفرضِ '' متنِ
|
||||||
defaults={'is_helpful': serializer.validated_data['isHelpful']},
|
# قبلیاش را پاک میکرد — یعنی مفیدترین بخشِ فیدبک از دست میرفت.
|
||||||
)
|
defaults = {'is_helpful': serializer.validated_data['isHelpful']}
|
||||||
|
if 'reason' in serializer.validated_data:
|
||||||
|
defaults['reason'] = serializer.validated_data['reason']
|
||||||
|
MessageFeedback.objects.update_or_create(message=message, defaults=defaults)
|
||||||
return Response(FeedbackResponseSerializer({'success': True}).data)
|
return Response(FeedbackResponseSerializer({'success': True}).data)
|
||||||
|
|||||||
218
rag/management/commands/rag_eval.py
Normal file
218
rag/management/commands/rag_eval.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
سنجشِ کیفیتِ پاسخهای دستیار روی دادهٔ واقعی.
|
||||||
|
|
||||||
|
سینیور: «مهمترین بخش پروژه اینه که ببینیم اون چیزی که AI میده بدردخوره،
|
||||||
|
چیزیه که میشه ازش یاد گرفت.» این فرمان همان را قابلِ دیدن میکند: مجموعهای
|
||||||
|
سوال را از مسیرِ واقعیِ retrieval + generation میگذراند و پرسش، تکههای
|
||||||
|
بازیابیشده (با امتیازشان) و پاسخ را کنارِ هم چاپ میکند تا آدم قضاوت کند.
|
||||||
|
|
||||||
|
# فقط بازیابی — هزینهاش تنها embedding است (کسری از سنت)
|
||||||
|
python manage.py rag_eval --grade نهم --retrieval-only
|
||||||
|
|
||||||
|
# کامل، با تولیدِ پاسخ
|
||||||
|
python manage.py rag_eval --grade نهم --chapters 1 4
|
||||||
|
|
||||||
|
# سوالهای خودتان
|
||||||
|
python manage.py rag_eval --questions data/eval/questions.json --json out.json
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from rag.services.config import rag_is_configured
|
||||||
|
from rag.services.generation import generate_answer
|
||||||
|
from rag.services.retrieval import retrieve_context
|
||||||
|
from rag.services.textbooks import TEXTBOOKS, get_textbook
|
||||||
|
|
||||||
|
# سوالهای پیشفرض به تفکیکِ (پایه، شمارهی فصل). عمداً از جنسِ همانجاییاند
|
||||||
|
# که بچهها گیر میکنند، نه سوالِ تعریفیِ ساده.
|
||||||
|
DEFAULT_QUESTIONS = {
|
||||||
|
('نهم', 1): [
|
||||||
|
'فرق مجموعهی تهی با مجموعهای که فقط عضو صفر دارد چیست؟',
|
||||||
|
'اجتماع و اشتراک دو مجموعه چه فرقی دارند؟ با یک مثال توضیح بده.',
|
||||||
|
],
|
||||||
|
('نهم', 2): [
|
||||||
|
'چطور بفهمم یک عدد گویا است یا گنگ؟',
|
||||||
|
'قدر مطلق یعنی چه و چرا همیشه مثبت است؟',
|
||||||
|
],
|
||||||
|
('نهم', 4): [
|
||||||
|
'چرا هر عددی به توان صفر میشود یک؟',
|
||||||
|
'چطور دو رادیکال را با هم جمع کنم؟',
|
||||||
|
],
|
||||||
|
('نهم', 5): [
|
||||||
|
'اتحاد مربع دوجملهای را با یک مثال توضیح بده.',
|
||||||
|
'تجزیه یعنی چه و به چه دردی میخورد؟',
|
||||||
|
],
|
||||||
|
('دهم', 2): [
|
||||||
|
'سینوس و کسینوس یک زاویه یعنی چه؟',
|
||||||
|
'دایرهی مثلثاتی چه کمکی به ما میکند؟',
|
||||||
|
],
|
||||||
|
('دهم', 4): [
|
||||||
|
'معادلهی درجه دوم را چطور با تشکیل مربع کامل حل کنم؟',
|
||||||
|
'دلتا چه چیزی را به ما میگوید؟',
|
||||||
|
],
|
||||||
|
('دهم', 5): [
|
||||||
|
'فرق دامنه و برد یک تابع چیست؟',
|
||||||
|
'از کجا بفهمم یک رابطه تابع است یا نه؟',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'اجرای مجموعهای سوالِ واقعی روی مسیرِ RAG و چاپِ پاسخها برای بازبینیِ انسانی.'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--subject', default='ریاضی')
|
||||||
|
parser.add_argument('--grade', help='مثلاً نهم؛ خالی یعنی همهی پایههای ثبتشده.')
|
||||||
|
parser.add_argument('--chapters', nargs='*', type=int, default=None)
|
||||||
|
parser.add_argument('--questions', help='فایلِ JSON: [{"grade","chapter","question"}, ...]')
|
||||||
|
parser.add_argument('--retrieval-only', action='store_true',
|
||||||
|
help='پاسخ تولید نکن؛ فقط تکههای بازیابیشده را نشان بده (ارزان).')
|
||||||
|
parser.add_argument('--json', dest='json_out', help='ذخیرهی نتیجه در این فایل.')
|
||||||
|
parser.add_argument('--top-k', type=int, default=4)
|
||||||
|
|
||||||
|
def handle(self, *args, **opts):
|
||||||
|
if not rag_is_configured():
|
||||||
|
raise CommandError('RAG پیکربندی نشده (کلیدها خالیاند یا RAG_FORCE_MOCK روشن است).')
|
||||||
|
|
||||||
|
cases = self._load_cases(opts)
|
||||||
|
if not cases:
|
||||||
|
raise CommandError('هیچ سوالی برای اجرا پیدا نشد.')
|
||||||
|
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING(
|
||||||
|
f'\n{len(cases)} سوال — '
|
||||||
|
+ ('فقط بازیابی' if opts['retrieval_only'] else 'بازیابی + تولیدِ پاسخ')
|
||||||
|
+ '\n'
|
||||||
|
))
|
||||||
|
|
||||||
|
results = []
|
||||||
|
empty_retrievals = 0
|
||||||
|
for index, case in enumerate(cases, start=1):
|
||||||
|
row = self._run_case(index, case, opts)
|
||||||
|
results.append(row)
|
||||||
|
if not row['chunks']:
|
||||||
|
empty_retrievals += 1
|
||||||
|
|
||||||
|
self._summary(results, empty_retrievals, opts)
|
||||||
|
|
||||||
|
if opts['json_out']:
|
||||||
|
Path(opts['json_out']).write_text(
|
||||||
|
json.dumps(results, ensure_ascii=False, indent=2), encoding='utf-8',
|
||||||
|
)
|
||||||
|
self.stdout.write(f"\nنتیجه در {opts['json_out']} ذخیره شد.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _load_cases(self, opts):
|
||||||
|
if opts['questions']:
|
||||||
|
path = Path(opts['questions'])
|
||||||
|
if not path.exists():
|
||||||
|
raise CommandError(f'فایل پیدا نشد: {path}')
|
||||||
|
raw = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
return [
|
||||||
|
{'grade': r['grade'], 'chapter': int(r['chapter']), 'question': r['question']}
|
||||||
|
for r in raw
|
||||||
|
]
|
||||||
|
|
||||||
|
grades = [opts['grade']] if opts['grade'] else sorted(
|
||||||
|
{b.grade for b in TEXTBOOKS.values()}
|
||||||
|
)
|
||||||
|
cases = []
|
||||||
|
for grade in grades:
|
||||||
|
if opts['grade'] and get_textbook(opts['subject'], grade) is None:
|
||||||
|
raise CommandError(f'کتابی برای «{opts["subject"]} {grade}» ثبت نشده است.')
|
||||||
|
for (g, chapter), questions in DEFAULT_QUESTIONS.items():
|
||||||
|
if g != grade:
|
||||||
|
continue
|
||||||
|
if opts['chapters'] and chapter not in opts['chapters']:
|
||||||
|
continue
|
||||||
|
cases.extend(
|
||||||
|
{'grade': grade, 'chapter': chapter, 'question': q} for q in questions
|
||||||
|
)
|
||||||
|
return cases
|
||||||
|
|
||||||
|
def _run_case(self, index, case, opts):
|
||||||
|
subject = opts['subject']
|
||||||
|
started = time.monotonic()
|
||||||
|
chunks, meta = retrieve_context(
|
||||||
|
case['question'], subject=subject, grade_title=case['grade'],
|
||||||
|
lesson_number=case['chapter'], limit=opts['top_k'],
|
||||||
|
)
|
||||||
|
retrieval_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.HTTP_INFO(
|
||||||
|
f"\n[{index}] {case['grade']} / فصل {case['chapter']} — {case['question']}"
|
||||||
|
))
|
||||||
|
self.stdout.write(
|
||||||
|
f" collection={meta.get('collection')} filter={meta.get('filterValue')} "
|
||||||
|
f"chunk={len(chunks)} ({retrieval_ms} ms)"
|
||||||
|
)
|
||||||
|
if not chunks:
|
||||||
|
self.stdout.write(self.style.ERROR(
|
||||||
|
' ⚠ هیچ تکهای پیدا نشد — یعنی مدل بدونِ پشتوانهی کتاب جواب میدهد.'
|
||||||
|
))
|
||||||
|
for chunk in chunks:
|
||||||
|
self.stdout.write(
|
||||||
|
f" • [{chunk.get('score', 0):.3f}] {chunk.get('title', '')[:40]}"
|
||||||
|
f" — {chunk.get('summary', '')[:90].replace(chr(10), ' ')}…"
|
||||||
|
)
|
||||||
|
|
||||||
|
row = {
|
||||||
|
**case,
|
||||||
|
'collection': meta.get('collection'),
|
||||||
|
'filterValue': meta.get('filterValue'),
|
||||||
|
'chunks': [
|
||||||
|
{'title': c.get('title'), 'score': c.get('score'), 'summary': c.get('summary')}
|
||||||
|
for c in chunks
|
||||||
|
],
|
||||||
|
'retrievalMs': retrieval_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts['retrieval_only']:
|
||||||
|
return row
|
||||||
|
|
||||||
|
answer, _follow_ups, gen_meta = generate_answer(
|
||||||
|
case['question'], chunks or None, case['grade'],
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(' پاسخ:'))
|
||||||
|
for line in answer.splitlines():
|
||||||
|
self.stdout.write(f' {line}')
|
||||||
|
row.update({'answer': answer, 'generation': gen_meta})
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _summary(self, results, empty_retrievals, opts):
|
||||||
|
total = len(results)
|
||||||
|
grounded = total - empty_retrievals
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING('\n\nخلاصه\n'))
|
||||||
|
self.stdout.write(
|
||||||
|
f' پوششِ کتاب: {grounded} از {total} سوال تکهی مرتبط پیدا کرد '
|
||||||
|
f'({100 * grounded / total:.0f}%).'
|
||||||
|
)
|
||||||
|
if empty_retrievals:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
f' {empty_retrievals} سوال بدونِ پشتوانه ماند — یا آن فصل ingest نشده '
|
||||||
|
'یا رشتهی فصل با چیزی که در Qdrant است نمیخورد.'
|
||||||
|
))
|
||||||
|
scored = [c['score'] for r in results for c in r['chunks'] if c.get('score') is not None]
|
||||||
|
if scored:
|
||||||
|
self.stdout.write(
|
||||||
|
f' میانگینِ امتیازِ شباهت: {sum(scored) / len(scored):.3f} '
|
||||||
|
f'(کمینه {min(scored):.3f}، بیشینه {max(scored):.3f})'
|
||||||
|
)
|
||||||
|
if not opts['retrieval_only']:
|
||||||
|
tokens = sum(
|
||||||
|
(r.get('generation') or {}).get('promptTokens', 0)
|
||||||
|
+ (r.get('generation') or {}).get('completionTokens', 0)
|
||||||
|
for r in results
|
||||||
|
)
|
||||||
|
latencies = [(r.get('generation') or {}).get('latencyMs', 0) for r in results]
|
||||||
|
self.stdout.write(f' مجموعِ توکن: {tokens:,}')
|
||||||
|
if latencies:
|
||||||
|
self.stdout.write(
|
||||||
|
f' تأخیرِ تولید: میانگین {sum(latencies) / len(latencies):.0f} ms، '
|
||||||
|
f'بیشینه {max(latencies)} ms'
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.HTTP_INFO(
|
||||||
|
'\n این فرمان نمره نمیدهد — قضاوتِ «آیا این جواب یاد میدهد؟» با آدم است.'
|
||||||
|
))
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
کشِ پاسخِ RAG برای سوالِ عیناً تکراری.
|
کشِ پاسخِ RAG برای سوالِ عیناً تکراری.
|
||||||
|
|
||||||
کلید = هشِ (collection + مقدارِ فیلترِ درس/فصل + متنِ نرمالشدهی سوال).
|
کلید = هشِ (collection + مقدارِ فیلترِ درس/فصل + پایه + متنِ نرمالشدهی سوال).
|
||||||
فقط مرحلهی گران (embedding + جستجوی Qdrant + تولیدِ GPT) رو رد میکنه؛
|
فقط مرحلهی گران (embedding + جستجوی Qdrant + تولیدِ GPT) رو رد میکنه؛
|
||||||
کوتای روزانه، idempotency و ثبتِ Message همچنان در view اجرا میشن.
|
کوتای روزانه، idempotency و ثبتِ Message همچنان در view اجرا میشن.
|
||||||
"""
|
"""
|
||||||
@@ -10,7 +10,9 @@ import hashlib
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
_PREFIX = 'rag:ans:v1:'
|
# v2: پایه هم واردِ کلید شد (پایین را ببینید). نسخهی prefix عمداً جلو رفت تا
|
||||||
|
# کلیدهای v1 که پایه در آنها نبود، خودبهخود بیاثر شوند.
|
||||||
|
_PREFIX = 'rag:ans:v2:'
|
||||||
|
|
||||||
|
|
||||||
def ttl() -> int:
|
def ttl() -> int:
|
||||||
@@ -25,8 +27,15 @@ def _norm(text: str) -> str:
|
|||||||
return ' '.join((text or '').split()).strip().lower()
|
return ' '.join((text or '').split()).strip().lower()
|
||||||
|
|
||||||
|
|
||||||
def make_key(collection, filter_value, question: str) -> str:
|
def make_key(collection, filter_value, question: str, grade_title: str = '') -> str:
|
||||||
raw = f'{collection}|{filter_value}|{_norm(question)}'
|
"""
|
||||||
|
⚠️ `grade_title` جزءِ کلید است چون تولیدِ پاسخ از این به بعد پایهآگاه است
|
||||||
|
(لحن و سطحِ توضیح از پایه میآید). بدونِ آن، وقتی subject/فصل مشخص نیست
|
||||||
|
collection و filter هر دو None میشوند و کلید فقط به متنِ سوال میماند —
|
||||||
|
یعنی پاسخی که برای دانشآموزِ نهم ساخته شده عیناً به دانشآموزِ سوم
|
||||||
|
برمیگشت.
|
||||||
|
"""
|
||||||
|
raw = f'{collection}|{filter_value}|{(grade_title or "").strip()}|{_norm(question)}'
|
||||||
return _PREFIX + hashlib.sha1(raw.encode('utf-8')).hexdigest()
|
return _PREFIX + hashlib.sha1(raw.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
نشده یا اون درس هنوز در Qdrant نیست.
|
نشده یا اون درس هنوز در Qdrant نیست.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -17,70 +18,131 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_DEFAULT_FOLLOW_UPS = ['یک مثال دیگه بزن', 'بیشتر توضیح بده']
|
_DEFAULT_FOLLOW_UPS = ['یک مثال دیگه بزن', 'بیشتر توضیح بده']
|
||||||
|
|
||||||
_SYSTEM = 'تو یک معلمِ فارسیزبانِ دبستانی هستی و پاسخها را روشن، ساده و کوتاه میدهی.'
|
# ⚠️ با هر تغییرِ معنادار در متنِ پرامپتهای پایین، این را یکی جلو ببرید.
|
||||||
|
# روی هر پیامِ دستیار ذخیره میشود تا وقتی لایک/دیسلایکِ کاربرها جمع شد،
|
||||||
|
# بشود گفت «کدام نسخهی پرامپت جوابِ بهتری میداد» — بدونِ این، همهی فیدبکها
|
||||||
|
# در یک کاسه میریزند و مقایسهی نسخهها ممکن نیست.
|
||||||
|
PROMPT_VERSION = 'tutor-v2-grade-aware'
|
||||||
|
|
||||||
|
# پایهها تا ششم دبستاناند، هفتم تا نهم متوسطهی اول و دهم به بعد متوسطهی دوم.
|
||||||
|
_SECONDARY_GRADES = {'هفتم', 'هشتم', 'نهم', 'دهم', 'یازدهم', 'دوازدهم'}
|
||||||
|
|
||||||
|
|
||||||
def _prompt_with_context(question: str, contexts: list[str]) -> str:
|
def _persona(grade_title: str) -> tuple[str, str]:
|
||||||
|
"""(توصیفِ معلم، توصیفِ دانشآموز) متناسب با پایه."""
|
||||||
|
grade = (grade_title or '').strip()
|
||||||
|
if grade in _SECONDARY_GRADES:
|
||||||
|
return f'معلمِ ریاضیِ پایهی {grade}', f'دانشآموزِ پایهی {grade}'
|
||||||
|
if grade:
|
||||||
|
return f'معلمِ پایهی {grade} دبستان', f'دانشآموزِ پایهی {grade}'
|
||||||
|
return 'معلمِ فارسیزبان', 'دانشآموز'
|
||||||
|
|
||||||
|
|
||||||
|
def system_prompt(grade_title: str = '') -> str:
|
||||||
|
"""
|
||||||
|
قبلاً «معلمِ دبستانی» ثابت بود. برای پایههای نهم/دهم این هم لحن را بچگانه
|
||||||
|
میکرد و هم مدل را وادار میکرد مفاهیمی مثل اتحاد، مثلثات و تابع را تا حدِ
|
||||||
|
دبستان ساده کند — یعنی دقیقاً همانجایی که بچهها گیر میکنند بیفایده
|
||||||
|
میشد. حالا لحن از پایه میآید.
|
||||||
|
"""
|
||||||
|
teacher, _ = _persona(grade_title)
|
||||||
|
return (
|
||||||
|
f'تو یک {teacher} و فارسیزبان هستی. پاسخها را روشن، دقیق و '
|
||||||
|
'گامبهگام میدهی و از نمادها و اصطلاحهای ریاضیِ همان پایه استفاده '
|
||||||
|
'میکنی؛ نه سادهتر از سطحِ کتاب، نه پیچیدهتر از آن.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_with_context(question: str, contexts: list[str], grade_title: str = '') -> str:
|
||||||
joined = '\n\n---\n\n'.join(contexts)
|
joined = '\n\n---\n\n'.join(contexts)
|
||||||
|
teacher, student = _persona(grade_title)
|
||||||
return f"""
|
return f"""
|
||||||
تو معلمِ دبستانی هستی. با تکیه بر «متنِ درس» زیر و با زبانِ خودت، به سوالِ
|
تو {teacher} هستی. با تکیه بر «متنِ درس» زیر و با زبانِ خودت، به سوالِ
|
||||||
دانشآموز پاسخِ روشن و ساده بده. لازم نیست پاسخ عیناً در متن باشد؛ میتوانی
|
{student} پاسخِ روشن و گامبهگام بده. لازم نیست پاسخ عیناً در متن باشد؛
|
||||||
از اطلاعاتِ متن نتیجهگیری و توضیح بدهی.
|
میتوانی از اطلاعاتِ متن نتیجهگیری و توضیح بدهی.
|
||||||
|
اگر مسئله چند مرحله دارد، مرحلهها را جدا و شمارهگذاریشده بنویس.
|
||||||
فقط اگر متنِ درس آشکارا دربارهی موضوعِ دیگریست و هیچ ربطی به سوال ندارد،
|
فقط اگر متنِ درس آشکارا دربارهی موضوعِ دیگریست و هیچ ربطی به سوال ندارد،
|
||||||
بنویس: «این موضوع در این درس پوشش داده نشده است.»
|
بنویس: «این موضوع در این درس پوشش داده نشده است.»
|
||||||
|
|
||||||
متنِ درس:
|
متنِ درس:
|
||||||
{joined}
|
{joined}
|
||||||
|
|
||||||
سوالِ دانشآموز: {question}
|
سوالِ {student}: {question}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _prompt_without_context(question: str) -> str:
|
def _prompt_without_context(question: str, grade_title: str = '') -> str:
|
||||||
|
teacher, student = _persona(grade_title)
|
||||||
return f"""
|
return f"""
|
||||||
به این سوالِ یک دانشآموزِ دبستانی به فارسی، ساده و کوتاه پاسخ بده.
|
تو {teacher} هستی. به این سوالِ یک {student} به فارسی، روشن و گامبهگام
|
||||||
|
پاسخ بده.
|
||||||
|
|
||||||
سوال: {question}
|
سوال: {question}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def generate_answer(question: str, context_chunks: list[dict] | None) -> tuple[str, list[str]]:
|
def generate_answer(question: str, context_chunks: list[dict] | None,
|
||||||
|
grade_title: str = '') -> tuple[str, list[str], dict]:
|
||||||
"""
|
"""
|
||||||
context_chunks: خروجیِ retrieve_context ([{title, summary, score}, ...]) یا None/[].
|
context_chunks: خروجیِ retrieve_context ([{title, summary, score}, ...]) یا None/[].
|
||||||
خروجی: (answer, follow_ups)
|
خروجی: (answer, follow_ups, meta)
|
||||||
|
|
||||||
|
`meta` همان چیزیست که روی پیامِ دستیار ذخیره میشود: مدل، نسخهی پرامپت،
|
||||||
|
توکنِ مصرفشده و مدتِ تولید. بدونِ اینها نه میشود فهمید کدام نسخهی پرامپت
|
||||||
|
بهتر جواب میدهد، نه اینکه چقدر از سهمیهی API خرج شده است.
|
||||||
"""
|
"""
|
||||||
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
|
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
|
||||||
prompt = _prompt_with_context(question, contexts) if contexts else _prompt_without_context(question)
|
prompt = (
|
||||||
|
_prompt_with_context(question, contexts, grade_title) if contexts
|
||||||
|
else _prompt_without_context(question, grade_title)
|
||||||
|
)
|
||||||
|
|
||||||
client = clients.get_openai_client()
|
client = clients.get_openai_client()
|
||||||
|
started = time.monotonic()
|
||||||
completion = client.chat.completions.create(
|
completion = client.chat.completions.create(
|
||||||
model=settings.OPENAI_CHAT_MODEL,
|
model=settings.OPENAI_CHAT_MODEL,
|
||||||
messages=[
|
messages=[
|
||||||
{'role': 'system', 'content': _SYSTEM},
|
{'role': 'system', 'content': system_prompt(grade_title)},
|
||||||
{'role': 'user', 'content': prompt},
|
{'role': 'user', 'content': prompt},
|
||||||
],
|
],
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
)
|
)
|
||||||
|
latency_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
answer = (completion.choices[0].message.content or '').strip()
|
answer = (completion.choices[0].message.content or '').strip()
|
||||||
if not answer:
|
if not answer:
|
||||||
answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.'
|
answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.'
|
||||||
return answer, list(_DEFAULT_FOLLOW_UPS)
|
|
||||||
|
usage = getattr(completion, 'usage', None)
|
||||||
|
meta = {
|
||||||
|
'model': settings.OPENAI_CHAT_MODEL,
|
||||||
|
'promptVersion': PROMPT_VERSION,
|
||||||
|
'latencyMs': latency_ms,
|
||||||
|
'promptTokens': getattr(usage, 'prompt_tokens', None) or 0,
|
||||||
|
'completionTokens': getattr(usage, 'completion_tokens', None) or 0,
|
||||||
|
'hadContext': bool(contexts),
|
||||||
|
}
|
||||||
|
return answer, list(_DEFAULT_FOLLOW_UPS), meta
|
||||||
|
|
||||||
|
|
||||||
def stream_answer(question: str, context_chunks: list[dict] | None) -> Iterator[str]:
|
def stream_answer(question: str, context_chunks: list[dict] | None,
|
||||||
|
grade_title: str = '') -> Iterator[str]:
|
||||||
"""
|
"""
|
||||||
همون generate_answer ولی توکنبهتوکن (برای SSE). فقط قطعههای متنِ پاسخ
|
همون generate_answer ولی توکنبهتوکن (برای SSE). فقط قطعههای متنِ پاسخ
|
||||||
را yield میکند؛ follow_upها ثابتاند (_DEFAULT_FOLLOW_UPS) و caller خودش
|
را yield میکند؛ follow_upها ثابتاند (_DEFAULT_FOLLOW_UPS) و caller خودش
|
||||||
اضافهشان میکند.
|
اضافهشان میکند.
|
||||||
"""
|
"""
|
||||||
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
|
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
|
||||||
prompt = _prompt_with_context(question, contexts) if contexts else _prompt_without_context(question)
|
prompt = (
|
||||||
|
_prompt_with_context(question, contexts, grade_title) if contexts
|
||||||
|
else _prompt_without_context(question, grade_title)
|
||||||
|
)
|
||||||
|
|
||||||
client = clients.get_openai_client()
|
client = clients.get_openai_client()
|
||||||
stream = client.chat.completions.create(
|
stream = client.chat.completions.create(
|
||||||
model=settings.OPENAI_CHAT_MODEL,
|
model=settings.OPENAI_CHAT_MODEL,
|
||||||
messages=[
|
messages=[
|
||||||
{'role': 'system', 'content': _SYSTEM},
|
{'role': 'system', 'content': system_prompt(grade_title)},
|
||||||
{'role': 'user', 'content': prompt},
|
{'role': 'user', 'content': prompt},
|
||||||
],
|
],
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
|
|||||||
85
rag/services/lesson_context.py
Normal file
85
rag/services/lesson_context.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
از یک `lessonId` به مقصدِ retrieval در Qdrant.
|
||||||
|
|
||||||
|
چرا لازم است: تا پیش از این، برای اینکه پاسخِ دستیار به کتاب استناد کند، اپ
|
||||||
|
باید `subject` + `grade` («نهم») + `lessonNumber` (شمارهی فصل) را خودش
|
||||||
|
میفرستاد — یعنی فرانت باید از جزئیاتِ داخلیِ نامگذاریِ collectionهای Qdrant
|
||||||
|
خبر میداشت. اگر نمیفرستاد (که حالتِ طبیعی است: اپ فقط میداند دانشآموز روی
|
||||||
|
کدام درس است)، retrieval کلاً رد میشد و مدل بدونِ پشتوانهی کتاب جواب میداد —
|
||||||
|
بیهیچ خطایی، فقط جوابِ ضعیفتر.
|
||||||
|
|
||||||
|
حالا که Grade/Unit از روی ساختارِ خودِ کتاب ساخته میشوند
|
||||||
|
(`seed_curriculum_from_textbook`)، همین زنجیره کافیست:
|
||||||
|
|
||||||
|
Lesson → Node → Unit(عنوان = «فصل ۴: توان و ریشه») → Grade(عنوان = «پایه نهم»)
|
||||||
|
|
||||||
|
پس اپ فقط `lessonId` میفرستد و بقیه اینجا درمیآید. مقادیرِ صریحِ درخواست
|
||||||
|
همیشه اولویت دارند؛ این فقط جای خالی را پر میکند.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .known_collections import MATH_CHAPTERS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# «پایه نهم» / «پایهٔ نهم» / «نهم دبستان» → «نهم»
|
||||||
|
_GRADE_PREFIX = re.compile(r'^\s*پایه[ٔ\s]*')
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_grade_title(title: str) -> str:
|
||||||
|
"""عنوانِ Grade در دیتابیس → همان رشتهای که در نامِ collection میآید."""
|
||||||
|
return _GRADE_PREFIX.sub('', (title or '').strip()).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_from_lesson(lesson_id) -> dict:
|
||||||
|
"""
|
||||||
|
{'subject', 'gradeTitle', 'chapter'} — هر کدام که پیدا شود.
|
||||||
|
|
||||||
|
هیچوقت استثنا نمیدهد: نبودنِ درس یا نخوردنِ عنوانِ فصل با کاتالوگ، یعنی
|
||||||
|
«نتوانستیم حدس بزنیم»، نه خطا — همان مسیرِ قبلی (پاسخِ عمومی) ادامه مییابد.
|
||||||
|
"""
|
||||||
|
if lesson_id in (None, ''):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
lesson_id = int(lesson_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from content.models import Lesson
|
||||||
|
|
||||||
|
lesson = (
|
||||||
|
Lesson.objects
|
||||||
|
.select_related('node__unit__grade')
|
||||||
|
.filter(pk=lesson_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 — اپ content ممکن است در دسترس نباشد
|
||||||
|
logger.exception('rag.lesson_context: خواندنِ درس %s شکست خورد', lesson_id)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if lesson is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
unit = lesson.node.unit
|
||||||
|
grade_title = normalize_grade_title(unit.grade.title)
|
||||||
|
if not grade_title:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# عنوانِ Unit همان رشتهی دقیقِ فصل است؛ در کاتالوگ دنبالش میگردیم تا هم
|
||||||
|
# subject و هم شمارهی فصل دربیاید. برابریِ رشتهای عمدیست: فیلترِ Qdrant
|
||||||
|
# هم روی همین رشتهی دقیق است، پس اگر اینجا نخورد آنجا هم نمیخورد.
|
||||||
|
unit_title = (unit.title or '').strip()
|
||||||
|
for (subject, grade), chapters in MATH_CHAPTERS.items():
|
||||||
|
if grade != grade_title:
|
||||||
|
continue
|
||||||
|
for number, title in chapters.items():
|
||||||
|
if title == unit_title:
|
||||||
|
return {'subject': subject, 'gradeTitle': grade_title, 'chapter': number}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'rag.lesson_context: عنوانِ فصل «%s» (پایه %s) در کاتالوگ نبود',
|
||||||
|
unit_title, grade_title,
|
||||||
|
)
|
||||||
|
return {'gradeTitle': grade_title}
|
||||||
Reference in New Issue
Block a user