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
|
||||
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']
|
||||
|
||||
@@ -37,6 +54,11 @@ class Message(models.Model):
|
||||
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}"
|
||||
|
||||
@@ -54,6 +54,9 @@ class ConversationMessagesResponseSerializer(serializers.Serializer):
|
||||
|
||||
class FeedbackRequestSerializer(serializers.Serializer):
|
||||
isHelpful = serializers.BooleanField()
|
||||
# اختیاری: «چرا». تنها چیزی که از خودِ 👍/👎 درنمیآید و برای اصلاحِ
|
||||
# پرامپت واقعاً به کار میآید.
|
||||
reason = serializers.CharField(max_length=300, required=False, allow_blank=True)
|
||||
|
||||
|
||||
class FeedbackResponseSerializer(serializers.Serializer):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError, transaction
|
||||
@@ -29,6 +30,32 @@ logger = logging.getLogger(__name__)
|
||||
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):
|
||||
"""
|
||||
⚠️ اضافه شده: ScopedRateThrottle استاندارد برای کاربرِ احرازهویتشده
|
||||
@@ -121,7 +148,10 @@ def generate_tutor_answer(
|
||||
context_chunks = context if isinstance(context, list) else None
|
||||
# استثناهای شبکه/provider اینجا خفه نمیشن؛ AskAIView اونها رو به
|
||||
# 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 ''
|
||||
answer = (
|
||||
@@ -223,14 +253,40 @@ class AskAIView(APIView):
|
||||
# اگر RAG پیکربندی نشده باشد context=None میماند و generate_tutor_answer
|
||||
# همان پاسخِ stub را میدهد. خطای شبکه/provider در همین try به 503
|
||||
# تبدیل و اسلاتِ رزروشده آزاد میشود.
|
||||
grade_id = v.get('gradeId')
|
||||
lesson_id = v.get('lessonId')
|
||||
# مقادیرِ ذخیرهشده روی مکالمه هم fallbackاند: در پیامِ دوم به بعد، اپ
|
||||
# فقط 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()
|
||||
grade_title = (v.get('grade') or '').strip()
|
||||
lesson_number = v.get('lessonNumber')
|
||||
if lesson_number is None:
|
||||
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 پیکربندیشده.
|
||||
# قراردادِ JSONِ غیراستریم دستنخورده میماند؛ Swagger که stream
|
||||
# نمیفرسته همون مسیرِ قبلی رو میگیره.
|
||||
@@ -248,6 +304,8 @@ class AskAIView(APIView):
|
||||
|
||||
used_chunks: list[dict] = []
|
||||
retrieval_meta: dict = {}
|
||||
generation_meta: dict = {}
|
||||
answer_from_cache = False
|
||||
context = None
|
||||
try:
|
||||
from rag.services.config import rag_is_configured
|
||||
@@ -258,13 +316,15 @@ class AskAIView(APIView):
|
||||
from rag.services.retrieval import resolve_target, retrieve_context
|
||||
|
||||
_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)
|
||||
if cached is not None:
|
||||
answer_text = cached['answer']
|
||||
follow_ups = list(cached['followUps'])
|
||||
used_chunks = cached['usedChunks']
|
||||
retrieval_meta = cached.get('retrievalMeta') or {}
|
||||
generation_meta = cached.get('generationMeta') or {}
|
||||
answer_from_cache = True
|
||||
else:
|
||||
used_chunks, retrieval_meta = retrieve_context(
|
||||
v['userQuestion'], subject=subject, grade_id=grade_id,
|
||||
@@ -274,10 +334,14 @@ class AskAIView(APIView):
|
||||
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||||
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': answer_text, 'followUps': follow_ups,
|
||||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||
'generationMeta': generation_meta,
|
||||
})
|
||||
else:
|
||||
answer_text, follow_ups = generate_tutor_answer(
|
||||
@@ -295,6 +359,7 @@ class AskAIView(APIView):
|
||||
role='assistant',
|
||||
text=answer_text,
|
||||
question_id=v.get('questionId') or None,
|
||||
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
||||
)
|
||||
|
||||
if idempotency_key:
|
||||
@@ -356,7 +421,7 @@ class AskAIView(APIView):
|
||||
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
|
||||
"""
|
||||
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
|
||||
|
||||
def _sse(event: str, payload: dict) -> str:
|
||||
@@ -364,8 +429,10 @@ class AskAIView(APIView):
|
||||
|
||||
def _gen():
|
||||
nonlocal used_today
|
||||
generation_meta: dict = {}
|
||||
answer_from_cache = False
|
||||
_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:
|
||||
cached = answer_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
@@ -373,6 +440,8 @@ class AskAIView(APIView):
|
||||
follow_ups = list(cached['followUps'])
|
||||
used_chunks = cached['usedChunks']
|
||||
retrieval_meta = cached.get('retrievalMeta') or {}
|
||||
generation_meta = cached.get('generationMeta') or {}
|
||||
answer_from_cache = True
|
||||
yield _sse('chunk', {'delta': answer_text})
|
||||
else:
|
||||
used_chunks, retrieval_meta = retrieve_context(
|
||||
@@ -384,14 +453,28 @@ class AskAIView(APIView):
|
||||
for c in used_chunks
|
||||
]
|
||||
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)
|
||||
yield _sse('chunk', {'delta': delta})
|
||||
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
|
||||
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': answer_text, 'followUps': follow_ups,
|
||||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||
'generationMeta': generation_meta,
|
||||
})
|
||||
except Exception:
|
||||
logger.exception('stream generate failed for user_id=%s', user.id)
|
||||
@@ -405,6 +488,7 @@ class AskAIView(APIView):
|
||||
assistant_message = Message.objects.create(
|
||||
conversation=conversation, role='assistant', text=answer_text,
|
||||
question_id=v.get('questionId') or None,
|
||||
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
||||
)
|
||||
if idempotency_key:
|
||||
AskIdempotency.objects.create(
|
||||
@@ -540,8 +624,11 @@ class MessageFeedbackView(APIView):
|
||||
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']},
|
||||
)
|
||||
# `reason` فقط وقتی نوشته میشود که در همین درخواست آمده باشد: کاربری
|
||||
# که رأیش را عوض میکند معمولاً دلیل نمینویسد، و پیشفرضِ '' متنِ
|
||||
# قبلیاش را پاک میکرد — یعنی مفیدترین بخشِ فیدبک از دست میرفت.
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user