""" گزارشِ کیفیت و مصرفِ دستیارِ هوش مصنوعی. سینیور دو چیز خواست و هر دو از همین یک جدول درمی‌آیند: «یک ساینتیشن برای لایک/دیسلایک بگیریم که بعداً بشود چک کرد کدام بهتر بوده» «اگر لیمیت خورد یا تمام شد بهم بگید که شارژش کنیم» نمونه: 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 '') )