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 '')
|
||||
)
|
||||
Reference in New Issue
Block a user