diff --git a/apps/application/serializers/application_stats.py b/apps/application/serializers/application_stats.py index e1cb5e19026..258d6acb667 100644 --- a/apps/application/serializers/application_stats.py +++ b/apps/application/serializers/application_stats.py @@ -1,23 +1,27 @@ # coding=utf-8 """ - @project: MaxKB - @Author:虎虎 - @file: application_stats.py - @date:2025/6/9 20:34 - @desc: +@project: MaxKB +@Author:虎虎 +@file: application_stats.py +@date:2025/6/9 20:34 +@desc: """ + import datetime import os from typing import Dict, List +import openpyxl from django.db import models -from django.db.models import QuerySet -from django.utils.translation import gettext_lazy as _ +from django.db.models import QuerySet, Sum, Count, F, Q, BigIntegerField, IntegerField, Value, Window +from django.db.models.functions import RowNumber, Coalesce +from django.http import HttpResponse +from django.utils.translation import gettext_lazy as _, gettext from django.utils import timezone from rest_framework import serializers -from application.models import ApplicationChatUserStats, Application -from common.db.search import native_search, get_dynamics_model +from application.models import ApplicationChatUserStats, Application, Chat, ChatRecord +from common.db.search import native_search, get_dynamics_model, page_search from common.exception.app_exception import AppApiException from common.utils.common import get_file_content from maxkb.conf import PROJECT_DIR @@ -37,25 +41,26 @@ class ApplicationStatsSerializer(serializers.Serializer): class ApplicationStatisticsSerializer(serializers.Serializer): workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID")) application_id = serializers.UUIDField(required=True, label=_("Application ID")) - start_time = serializers.DateField(format='%Y-%m-%d', label=_("Start time")) - end_time = serializers.DateField(format='%Y-%m-%d', label=_("End time")) + name = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("User Name")) + start_time = serializers.DateField(format="%Y-%m-%d", label=_("Start time")) + end_time = serializers.DateField(format="%Y-%m-%d", label=_("End time")) def is_valid(self, *, raise_exception=False): super().is_valid(raise_exception=True) - workspace_id = self.data.get('workspace_id') - query_set = QuerySet(Application).filter(id=self.data.get('application_id')) + workspace_id = self.data.get("workspace_id") + query_set = QuerySet(Application).filter(id=self.data.get("application_id")) if workspace_id: query_set = query_set.filter(workspace_id=workspace_id) if not query_set.exists(): - raise AppApiException(500, _('Application id does not exist')) + raise AppApiException(500, _("Application id does not exist")) def get_end_time(self): - d = datetime.datetime.strptime(self.data.get('end_time'), '%Y-%m-%d').date() + d = datetime.datetime.strptime(self.data.get("end_time"), "%Y-%m-%d").date() naive = datetime.datetime.combine(d, datetime.time.max) return timezone.make_aware(naive, timezone.get_default_timezone()) def get_start_time(self): - d = datetime.datetime.strptime(self.data.get('start_time'), '%Y-%m-%d').date() + d = datetime.datetime.strptime(self.data.get("start_time"), "%Y-%m-%d").date() naive = datetime.datetime.combine(d, datetime.time.min) return timezone.make_aware(naive, timezone.get_default_timezone()) @@ -65,12 +70,17 @@ def get_customer_count_trend(self, with_valid=True): start_time = self.get_start_time() end_time = self.get_end_time() return native_search( - {'default_sql': QuerySet(ApplicationChatUserStats).filter( - application_id=self.data.get('application_id'), - create_time__gte=start_time, - create_time__lte=end_time)}, + { + "default_sql": QuerySet(ApplicationChatUserStats).filter( + application_id=self.data.get("application_id"), + create_time__gte=start_time, + create_time__lte=end_time, + ) + }, select_string=get_file_content( - os.path.join(PROJECT_DIR, "apps", "application", 'sql', 'customer_count_trend.sql'))) + os.path.join(PROJECT_DIR, "apps", "application", "sql", "customer_count_trend.sql") + ), + ) def get_chat_record_aggregate_trend(self, with_valid=True): if with_valid: @@ -78,29 +88,53 @@ def get_chat_record_aggregate_trend(self, with_valid=True): start_time = self.get_start_time() end_time = self.get_end_time() chat_record_aggregate_trend = native_search( - {'default_sql': QuerySet(model=get_dynamics_model( - {'application_chat.application_id': models.UUIDField(), - 'application_chat_record.create_time': models.DateTimeField()})).filter( - **{'application_chat.application_id': self.data.get('application_id'), - 'application_chat_record.create_time__gte': start_time, - 'application_chat_record.create_time__lte': end_time} - )}, + { + "default_sql": QuerySet( + model=get_dynamics_model( + { + "application_chat.application_id": models.UUIDField(), + "application_chat_record.create_time": models.DateTimeField(), + } + ) + ).filter( + **{ + "application_chat.application_id": self.data.get("application_id"), + "application_chat_record.create_time__gte": start_time, + "application_chat_record.create_time__lte": end_time, + } + ) + }, select_string=get_file_content( - os.path.join(PROJECT_DIR, "apps", "application", 'sql', 'chat_record_count_trend.sql'))) + os.path.join(PROJECT_DIR, "apps", "application", "sql", "chat_record_count_trend.sql") + ), + ) customer_count_trend = self.get_customer_count_trend(with_valid=False) return self.merge_customer_chat_record(chat_record_aggregate_trend, customer_count_trend) def merge_customer_chat_record(self, chat_record_aggregate_trend: List[Dict], customer_count_trend: List[Dict]): - return [{**self.find(chat_record_aggregate_trend, lambda c: c.get('day').strftime('%Y-%m-%d') == day, - {'star_num': 0, 'trample_num': 0, 'tokens_num': 0, 'chat_record_count': 0, - 'customer_num': 0, - 'day': day}), - **self.find(customer_count_trend, lambda c: c.get('day').strftime('%Y-%m-%d') == day, - {'customer_added_count': 0})} - for - day in - self.get_days_between_dates(self.data.get('start_time'), self.data.get('end_time'))] + return [ + { + **self.find( + chat_record_aggregate_trend, + lambda c: c.get("day").strftime("%Y-%m-%d") == day, + { + "star_num": 0, + "trample_num": 0, + "tokens_num": 0, + "chat_record_count": 0, + "customer_num": 0, + "day": day, + }, + ), + **self.find( + customer_count_trend, + lambda c: c.get("day").strftime("%Y-%m-%d") == day, + {"customer_added_count": 0}, + ), + } + for day in self.get_days_between_dates(self.data.get("start_time"), self.data.get("end_time")) + ] @staticmethod def find(source_list, condition, default): @@ -111,12 +145,12 @@ def find(source_list, condition, default): @staticmethod def get_days_between_dates(start_date, end_date): - start_date = datetime.datetime.strptime(start_date, '%Y-%m-%d') - end_date = datetime.datetime.strptime(end_date, '%Y-%m-%d') + start_date = datetime.datetime.strptime(start_date, "%Y-%m-%d") + end_date = datetime.datetime.strptime(end_date, "%Y-%m-%d") days = [] current_date = start_date while current_date <= end_date: - days.append(current_date.strftime('%Y-%m-%d')) + days.append(current_date.strftime("%Y-%m-%d")) current_date += datetime.timedelta(days=1) return days @@ -126,17 +160,32 @@ def get_token_usage_statistics(self, with_valid=True): start_time = self.get_start_time() end_time = self.get_end_time() get_token_usage = native_search( - {'default_sql': QuerySet(model=get_dynamics_model( - {'application_chat.application_id': models.UUIDField(), - 'application_chat_record.create_time': models.DateTimeField()})).filter( - **{'application_chat.application_id': self.data.get('application_id'), - 'application_chat_record.create_time__gte': start_time, - 'application_chat_record.create_time__lte': end_time} - )}, + { + "default_sql": QuerySet( + model=get_dynamics_model( + { + "application_chat.application_id": models.UUIDField(), + "application_chat_record.create_time": models.DateTimeField(), + } + ) + ).filter( + **{ + "application_chat.application_id": self.data.get("application_id"), + "application_chat_record.create_time__gte": start_time, + "application_chat_record.create_time__lte": end_time, + } + ) + }, select_string=get_file_content( - os.path.join(PROJECT_DIR, "apps", "application", 'sql', - ('get_token_usage_ee.sql' if ['PE', 'EE'].__contains__( - edition) else 'get_token_usage.sql')))) + os.path.join( + PROJECT_DIR, + "apps", + "application", + "sql", + ("get_token_usage_ee.sql" if ["PE", "EE"].__contains__(edition) else "get_token_usage.sql"), + ) + ), + ) return get_token_usage def get_top_questions_statistics(self, with_valid=True): @@ -145,14 +194,181 @@ def get_top_questions_statistics(self, with_valid=True): start_time = self.get_start_time() end_time = self.get_end_time() get_top_questions = native_search( - {'default_sql': QuerySet(model=get_dynamics_model( - {'application_chat.application_id': models.UUIDField(), - 'application_chat_record.create_time': models.DateTimeField()})).filter( - **{'application_chat.application_id': self.data.get('application_id'), - 'application_chat_record.create_time__gte': start_time, - 'application_chat_record.create_time__lte': end_time} - )}, + { + "default_sql": QuerySet( + model=get_dynamics_model( + { + "application_chat.application_id": models.UUIDField(), + "application_chat_record.create_time": models.DateTimeField(), + } + ) + ).filter( + **{ + "application_chat.application_id": self.data.get("application_id"), + "application_chat_record.create_time__gte": start_time, + "application_chat_record.create_time__lte": end_time, + } + ) + }, select_string=get_file_content( - os.path.join(PROJECT_DIR, "apps", "application", 'sql', ( - 'top_questions_ee.sql' if ['PE', 'EE'].__contains__(edition) else 'top_questions.sql')))) + os.path.join( + PROJECT_DIR, + "apps", + "application", + "sql", + ("top_questions_ee.sql" if ["PE", "EE"].__contains__(edition) else "top_questions.sql"), + ) + ), + ) return get_top_questions + + def _get_chat_queryset(self): + """当前智能体范围内未删除、带用户标识的对话。""" + queryset = Chat.objects.filter( + application_id=self.data.get("application_id"), + is_deleted=False, + chat_user_id__isnull=False, + ).exclude(chat_user_id="") + name = self.data.get("name") + if name: + queryset = queryset.filter(asker__username__contains=name) + return queryset + + @staticmethod + def _build_asker_map(base_queryset): + """一次查询拿到每个 (chat_user_id, chat_user_type) 最新的 asker。""" + latest_rows = ( + base_queryset.annotate( + _rn=Window( + expression=RowNumber(), + partition_by=[F("chat_user_id"), F("chat_user_type")], + order_by=F("create_time").desc(), + ) + ) + .filter(_rn=1) + .values("chat_user_id", "chat_user_type", "asker") + ) + return {(row["chat_user_id"], row["chat_user_type"]): row["asker"] for row in latest_rows} + + def _get_ranking_queryset(self, kind): + """按用户统计当前智能体同期 Tokens / 对话次数,返回 (聚合 queryset, 基础 queryset)。""" + start_time = self.get_start_time() + end_time = self.get_end_time() + base_queryset = self._get_chat_queryset() + record_time_filter = Q( + chatrecord__create_time__gte=start_time, + chatrecord__create_time__lte=end_time, + ) + queryset = ( + base_queryset.filter(record_time_filter) + .values("chat_user_id", "chat_user_type") + .annotate( + total_tokens=Coalesce( + Sum(F("chatrecord__message_tokens") + F("chatrecord__answer_tokens"), filter=record_time_filter), + Value(0), + output_field=BigIntegerField(), + ), + chat_record_count=Count("chatrecord__id", filter=record_time_filter, distinct=True), + ) + ) + if kind == "questions": + queryset = queryset.order_by("-chat_record_count", "chat_user_id") + else: + queryset = queryset.order_by("-total_tokens", "chat_user_id") + return queryset, base_queryset + + def get_ranking_statistics(self, kind, current_page, page_size, with_valid=True): + """分页查询当前智能体的用户排行,kind 为 userTokens / questions。""" + if with_valid: + self.is_valid(raise_exception=True) + queryset, base_queryset = self._get_ranking_queryset(kind) + asker_map = self._build_asker_map(base_queryset) + return page_search( + current_page, + page_size, + queryset, + lambda item: { + "chat_user_id": item["chat_user_id"], + "chat_user_type": item["chat_user_type"], + "asker": asker_map.get((item["chat_user_id"], item["chat_user_type"])), + "total_tokens": item["total_tokens"], + "chat_record_count": item["chat_record_count"], + }, + ) + + def get_tokens_aggregation(self, with_valid=True): + """当前智能体同期 Tokens 总量,不受用户名搜索影响。""" + if with_valid: + self.is_valid(raise_exception=True) + start_time = self.get_start_time() + end_time = self.get_end_time() + return ChatRecord.objects.filter( + chat__application_id=self.data.get("application_id"), + chat__is_deleted=False, + create_time__gte=start_time, + create_time__lte=end_time, + ).aggregate( + total_tokens=Coalesce(Sum(F("message_tokens") + F("answer_tokens"), output_field=IntegerField()), Value(0)) + )["total_tokens"] + + def get_chat_record_aggregation(self, with_valid=True): + """当前智能体同期对话次数,不受用户名搜索影响。""" + if with_valid: + self.is_valid(raise_exception=True) + start_time = self.get_start_time() + end_time = self.get_end_time() + return ChatRecord.objects.filter( + chat__application_id=self.data.get("application_id"), + chat__is_deleted=False, + create_time__gte=start_time, + create_time__lte=end_time, + ).count() + + def export_ranking_statistics(self, kind, with_valid=True): + """按当前筛选导出完整用户排行。""" + if with_valid: + self.is_valid(raise_exception=True) + total = ( + self.get_tokens_aggregation(with_valid=False) + if kind == "userTokens" + else self.get_chat_record_aggregation(with_valid=False) + ) + queryset, base_queryset = self._get_ranking_queryset(kind) + asker_map = self._build_asker_map(base_queryset) + workbook = openpyxl.Workbook(write_only=True) + worksheet = workbook.create_sheet(title="Sheet1") + headers = [ + gettext("ranking"), + gettext("User Name"), + gettext("Token consumption"), + gettext("proportion"), + gettext("number of questions"), + gettext("Average tokens per request"), + ] + worksheet.append(headers) + index = 0 + for item in queryset: + index += 1 + user_info = asker_map.get((item["chat_user_id"], item["chat_user_type"])) or {} + username = user_info.get("username", "") + total_tokens = item.get("total_tokens", 0) + chat_record_count = item.get("chat_record_count", 0) + proportion = ( + (chat_record_count / total if total else 0) + if kind == "questions" + else (total_tokens / total if total else 0) + ) + worksheet.append( + [ + index, + username, + total_tokens, + proportion, + chat_record_count, + total_tokens / chat_record_count if chat_record_count else 0, + ] + ) + response = HttpResponse(content_type="application/vnd.ms-excel") + response["Content-Disposition"] = 'attachment; filename="data.xlsx"' + workbook.save(response) + return response diff --git a/apps/application/sql/get_token_usage.sql b/apps/application/sql/get_token_usage.sql index cf7ce2c6934..a71ef3f1839 100644 --- a/apps/application/sql/get_token_usage.sql +++ b/apps/application/sql/get_token_usage.sql @@ -1,4 +1,5 @@ SELECT SUM(application_chat_record.message_tokens + application_chat_record.answer_tokens) as "token_usage", + COUNT(application_chat_record."id") as "chat_record_count", MAX(COALESCE(application_chat.asker ->>'username', '游客')) as "username" FROM application_chat_record application_chat_record LEFT JOIN application_chat application_chat ON application_chat."id" = application_chat_record.chat_id diff --git a/apps/application/sql/get_token_usage_ee.sql b/apps/application/sql/get_token_usage_ee.sql index b48a833fe50..86d8ed877fb 100644 --- a/apps/application/sql/get_token_usage_ee.sql +++ b/apps/application/sql/get_token_usage_ee.sql @@ -1,4 +1,5 @@ SELECT SUM(application_chat_record.message_tokens + application_chat_record.answer_tokens) as "token_usage", + COUNT(application_chat_record."id") as "chat_record_count", MAX(COALESCE(chat_user.username, application_chat.asker ->>'username', '游客')) as "username" FROM application_chat_record application_chat_record LEFT JOIN application_chat application_chat ON application_chat."id" = application_chat_record.chat_id @@ -7,4 +8,4 @@ FROM application_chat_record application_chat_record GROUP BY application_chat.chat_user_id ORDER BY - "token_usage" DESC \ No newline at end of file + "token_usage" DESC diff --git a/apps/application/sql/top_questions.sql b/apps/application/sql/top_questions.sql index 1ddc2b3f5f1..76c4b25ac7f 100644 --- a/apps/application/sql/top_questions.sql +++ b/apps/application/sql/top_questions.sql @@ -1,4 +1,5 @@ SELECT COUNT(application_chat_record."id") AS chat_record_count, + SUM(application_chat_record.message_tokens + application_chat_record.answer_tokens) AS token_usage, MAX(COALESCE(application_chat.asker ->>'username', '游客')) as "username" FROM application_chat_record application_chat_record LEFT JOIN application_chat application_chat ON application_chat."id" = application_chat_record.chat_id diff --git a/apps/application/sql/top_questions_ee.sql b/apps/application/sql/top_questions_ee.sql index 86a01ab6138..188cfe4ab1f 100644 --- a/apps/application/sql/top_questions_ee.sql +++ b/apps/application/sql/top_questions_ee.sql @@ -1,4 +1,5 @@ SELECT COUNT(application_chat_record."id") AS chat_record_count, + SUM(application_chat_record.message_tokens + application_chat_record.answer_tokens) AS token_usage, MAX(COALESCE(chat_user.username, application_chat.asker ->>'username', '游客')) as "username" FROM application_chat_record application_chat_record LEFT JOIN application_chat application_chat ON application_chat."id" = application_chat_record.chat_id diff --git a/apps/application/tests/test_application_ranking.py b/apps/application/tests/test_application_ranking.py new file mode 100644 index 00000000000..1f98d2629a6 --- /dev/null +++ b/apps/application/tests/test_application_ranking.py @@ -0,0 +1,186 @@ +# coding=utf-8 +"""应用概览用户排行(分页 / 汇总 / 导出)测试。""" + +import io + +from django.test import TestCase +from django.utils import timezone +from openpyxl import load_workbook + +from application.models import Application, ApplicationFolder, Chat, ChatRecord +from application.serializers.application_stats import ApplicationStatisticsSerializer +from users.models import User + + +def make_user(username): + return User.objects.create( + username=username, + nick_name=username, + password="x", + role="USER", + source="LOCAL", + ) + + +class ApplicationRankingSerializerTests(TestCase): + def setUp(self): + today = timezone.localdate() + self.today_str = today.strftime("%Y-%m-%d") + self.user = make_user("owner") + folder = ApplicationFolder.objects.create( + id="default", + name="默认分组", + workspace_id="default", + user=self.user, + ) + self.application = Application.objects.create( + name="测试应用", + workspace_id="default", + folder=folder, + user=self.user, + model_setting={"prompt": "hello"}, + model_params_setting={}, + knowledge_setting={}, + icon="./favicon.ico", + work_flow={}, + ) + self.serializer_data = { + "application_id": str(self.application.id), + "workspace_id": "default", + "start_time": self.today_str, + "end_time": self.today_str, + } + + @staticmethod + def make_chat(application, chat_user_id, username, create_time=None): + chat = Chat.objects.create( + application=application, + abstract="", + chat_user_id=chat_user_id, + chat_user_type="ANONYMOUS_USER", + asker={"username": username}, + ) + if create_time is not None: + Chat.objects.filter(id=chat.id).update(create_time=create_time) + return chat + + @staticmethod + def make_record(chat, message_tokens, answer_tokens, create_time=None): + record = ChatRecord.objects.create( + chat=chat, + problem_text="问题", + answer_text="答案", + message_tokens=message_tokens, + answer_tokens=answer_tokens, + index=1, + ) + if create_time is not None: + ChatRecord.objects.filter(id=record.id).update(create_time=create_time) + return record + + def test_user_tokens_ranking_order_and_pagination(self): + # u1: 30 tokens, u2: 50 tokens, u3: 40 tokens + c1 = self.make_chat(self.application, "u1", "alice") + self.make_record(c1, 10, 20) + c2 = self.make_chat(self.application, "u2", "bob") + self.make_record(c2, 30, 20) + c3 = self.make_chat(self.application, "u3", "carol") + self.make_record(c3, 20, 20) + + page = ApplicationStatisticsSerializer(data=self.serializer_data).get_ranking_statistics("userTokens", 1, 2) + self.assertEqual(page.total, 3) + self.assertEqual(page.size, 2) + self.assertEqual(page.current, 1) + self.assertEqual(len(page.records), 2) + # 按 total_tokens 降序 + self.assertEqual(page.records[0]["chat_user_id"], "u2") + self.assertEqual(page.records[1]["chat_user_id"], "u3") + self.assertEqual(page.records[0]["asker"]["username"], "bob") + self.assertEqual(page.records[0]["total_tokens"], 50) + self.assertEqual(page.records[0]["chat_record_count"], 1) + + page2 = ApplicationStatisticsSerializer(data=self.serializer_data).get_ranking_statistics("userTokens", 2, 2) + self.assertEqual(len(page2.records), 1) + self.assertEqual(page2.records[0]["chat_user_id"], "u1") + + def test_questions_ranking_order(self): + c1 = self.make_chat(self.application, "u1", "alice") + self.make_record(c1, 1, 1) + self.make_record(c1, 1, 1) + c2 = self.make_chat(self.application, "u2", "bob") + self.make_record(c2, 1, 1) + + page = ApplicationStatisticsSerializer(data=self.serializer_data).get_ranking_statistics("questions", 1, 10) + self.assertEqual(page.total, 2) + # 按对话次数降序 + self.assertEqual(page.records[0]["chat_user_id"], "u1") + self.assertEqual(page.records[0]["chat_record_count"], 2) + + def test_ranking_name_search(self): + c1 = self.make_chat(self.application, "u1", "alice") + self.make_record(c1, 10, 0) + c2 = self.make_chat(self.application, "u2", "bob") + self.make_record(c2, 5, 0) + + data = {**self.serializer_data, "name": "ali"} + page = ApplicationStatisticsSerializer(data=data).get_ranking_statistics("userTokens", 1, 10) + self.assertEqual(page.total, 1) + self.assertEqual(page.records[0]["chat_user_id"], "u1") + + def test_aggregations(self): + c1 = self.make_chat(self.application, "u1", "alice") + self.make_record(c1, 10, 20) + self.make_record(c1, 5, 5) + serializer = ApplicationStatisticsSerializer(data=self.serializer_data) + self.assertEqual(serializer.get_tokens_aggregation(), 40) + self.assertEqual(serializer.get_chat_record_aggregation(), 2) + + def test_export(self): + c1 = self.make_chat(self.application, "u1", "alice") + self.make_record(c1, 10, 20) + c2 = self.make_chat(self.application, "u2", "bob") + self.make_record(c2, 5, 5) + response = ApplicationStatisticsSerializer(data=self.serializer_data).export_ranking_statistics("userTokens") + self.assertEqual(response.status_code, 200) + self.assertIn("spreadsheet", response["Content-Type"]) + # 导出内容为 xlsx(zip 魔数) + raw = b"".join(response.streaming_content) if response.streaming else response.content + self.assertEqual(raw[:2], b"PK") + + def _workbook_rows(self, kind): + serializer = ApplicationStatisticsSerializer(data=self.serializer_data) + response = serializer.export_ranking_statistics(kind) + raw = b"".join(response.streaming_content) if response.streaming else response.content + workbook = load_workbook(io.BytesIO(raw)) + return workbook.active + + def test_export_user_tokens_cells(self): + c1 = self.make_chat(self.application, "u1", "alice") + self.make_record(c1, 30, 0) # 30 tokens + c2 = self.make_chat(self.application, "u2", "bob") + self.make_record(c2, 5, 5) # 10 tokens + ws = self._workbook_rows("userTokens") + # 表头 + 2 行 + self.assertEqual(ws.max_row, 3) + self.assertEqual(ws.cell(2, 2).value, "bob") # 40 tokens -> 第一 + self.assertEqual(ws.cell(2, 3).value, 40) + self.assertEqual(ws.cell(3, 2).value, "alice") + self.assertEqual(ws.cell(3, 3).value, 30) + # 占比 = 该用户 tokens / 总 tokens + self.assertAlmostEqual(ws.cell(2, 4).value, 40 / 70) + self.assertAlmostEqual(ws.cell(3, 4).value, 30 / 70) + + def test_export_questions_cells(self): + c1 = self.make_chat(self.application, "u1", "alice") + self.make_record(c1, 10, 0) + self.make_record(c1, 10, 0) # 2 次对话 + c2 = self.make_chat(self.application, "u2", "bob") + self.make_record(c2, 5, 0) # 1 次对话 + ws = self._workbook_rows("questions") + self.assertEqual(ws.max_row, 3) + # 按对话次数降序:alice(2) 在前 + self.assertEqual(ws.cell(2, 2).value, "alice") + self.assertEqual(ws.cell(2, 5).value, 2) + # 占比 = 对话次数 / 总对话次数 + self.assertAlmostEqual(ws.cell(2, 4).value, 2 / 3) + self.assertAlmostEqual(ws.cell(3, 4).value, 1 / 3) diff --git a/apps/application/tests/test_chat_export.py b/apps/application/tests/test_chat_export.py new file mode 100644 index 00000000000..7a29f0072eb --- /dev/null +++ b/apps/application/tests/test_chat_export.py @@ -0,0 +1,119 @@ +# coding=utf-8 + +from django.test import SimpleTestCase + +from application.serializers.application_chat import ApplicationChatQuerySerializers + + +class ChatExportToRowTests(SimpleTestCase): + def setUp(self): + def make_row(details): + return { + "chat_id": "chat-1", + "abstract": "abstract", + "problem_text": "", + "details": details, + "answer_text": "", + "vote_status": None, + "vote_reason": None, + "vote_other_content": None, + "improve_paragraph_list": [], + "asker": {"username": "u"}, + "message_tokens": 1, + "answer_tokens": 2, + "ip_address": "1.1.1.1", + "source": None, + "run_time": 1.0, + "create_time": None, + } + + self.make_row = make_row + self.question = {"type": "question-node", "answer": "optimized"} + self.search = { + "type": "search-knowledge-node", + "name": "kb", + "paragraph_list": [{"title": "t", "content": "c"}], + } + + def test_dict_form(self): + row = self.make_row({"q": self.question, "s": self.search}) + result = ApplicationChatQuerySerializers.to_row(row) + self.assertEqual(result[3], "optimized") + self.assertEqual(result[8], "kb:1") + self.assertIn("kb:\nt:\nc", result[9]) + + def test_list_form(self): + row = self.make_row([self.question, self.search]) + result = ApplicationChatQuerySerializers.to_row(row) + self.assertEqual(result[3], "optimized") + self.assertEqual(result[8], "kb:1") + self.assertIn("kb:\nt:\nc", result[9]) + + def test_loop_nested_form(self): + loop = {"type": "loop-node", "loop_node_data": [{"q": self.question, "s": self.search}]} + row = self.make_row([loop]) + result = ApplicationChatQuerySerializers.to_row(row) + self.assertEqual(result[3], "optimized") + self.assertEqual(result[8], "kb:1") + self.assertIn("kb:\nt:\nc", result[9]) + + def test_v3_raw_list_form(self): + start = {"type": "start-node", "name": "开始", "question": "你好"} + ai = {"type": "ai-chat-node", "name": "AI 对话", "node_id": "n1", "answer": "你好!有什么我可以帮你的吗?"} + row = self.make_row([start, ai]) + result = ApplicationChatQuerySerializers.to_row(row) + self.assertEqual(result[2], "你好") + self.assertEqual(result[4], "你好!有什么我可以帮你的吗?") + + def test_v3_execution_details_form(self): + start = {"type": "start-node", "name": "开始", "question": "你好"} + ai = {"type": "ai-chat-node", "name": "AI 对话", "node_id": "n1", "answer": "hi"} + details = [{"execution_details": [start, ai], "paragraph_list": [], "padding_problem_text": None}] + row = self.make_row(details) + result = ApplicationChatQuerySerializers.to_row(row) + self.assertEqual(result[2], "你好") + self.assertEqual(result[4], "hi") + + def test_v3_answer_includes_reasoning(self): + ai = { + "type": "ai-chat-node", + "name": "AI 对话", + "node_id": "n1", + "reasoning_content": "思考过程", + "answer": "答案", + } + row = self.make_row([ai]) + result = ApplicationChatQuerySerializers.to_row(row) + self.assertEqual(result[4], "思考过程\n\n答案") + + def test_v3_answer_from_node_messages(self): + ai = { + "type": "ai-chat-node", + "name": "AI 对话", + "node_id": "n1", + "messages": [ + {"type": "REASONING", "content": "思考过程"}, + {"type": "TEXT", "content": "你好"}, + ], + } + row = self.make_row([ai]) + result = ApplicationChatQuerySerializers.to_row(row) + self.assertEqual(result[4], "思考过程\n\n你好") + + def test_v3_answer_from_record_messages(self): + details = [ + { + "messages": [ + {"type": "REASONING", "content": "思考过程"}, + {"type": "TEXT", "content": "你好"}, + ], + "execution_details": [], + } + ] + result = ApplicationChatQuerySerializers.to_row(self.make_row(details)) + self.assertEqual(result[4], "思考过程\n\n你好") + + def test_empty_details(self): + for details in ({}, None, []): + result = ApplicationChatQuerySerializers.to_row(self.make_row(details)) + self.assertEqual((result[3], result[8], result[9]), ("", "", "")) diff --git a/apps/application/urls.py b/apps/application/urls.py index c154c27dee3..e3f4da2ff6d 100644 --- a/apps/application/urls.py +++ b/apps/application/urls.py @@ -22,6 +22,12 @@ path('workspace//application//application_stats', views.ApplicationStats.as_view()), path('workspace//application//application_token_usage', views.ApplicationStats.TokenUsageStatistics.as_view()), path('workspace//application//top_questions', views.ApplicationStats.TopQuestionsStatistics.as_view()), + path('workspace//application//application_token_usage//', views.ApplicationStats.TokenUsageRanking.as_view()), + path('workspace//application//application_token_usage/export', views.ApplicationStats.TokenUsageExport.as_view()), + path('workspace//application//top_questions//', views.ApplicationStats.TopQuestionsRanking.as_view()), + path('workspace//application//top_questions/export', views.ApplicationStats.TopQuestionsExport.as_view()), + path('workspace//application//tokens/aggregation', views.ApplicationStats.TokensAggregation.as_view()), + path('workspace//application//chat_record/aggregation', views.ApplicationStats.ChatRecordAggregation.as_view()), path('workspace//application//application_key/', views.ApplicationKey.Operate.as_view()), path('workspace//application//application_key//', views.ApplicationKey.Page.as_view()), path('workspace//application//export', views.ApplicationAPI.Export.as_view()), diff --git a/apps/application/views/application_stats.py b/apps/application/views/application_stats.py index c4fbe65bf9b..dae396a6b98 100644 --- a/apps/application/views/application_stats.py +++ b/apps/application/views/application_stats.py @@ -1,11 +1,12 @@ # coding=utf-8 """ - @project: MaxKB - @Author:虎虎 - @file: application_stats.py - @date:2025/6/9 20:30 - @desc: +@project: MaxKB +@Author:虎虎 +@file: application_stats.py +@date:2025/6/9 20:30 +@desc: """ + from drf_spectacular.utils import extend_schema from rest_framework.request import Request from rest_framework.views import APIView @@ -27,80 +28,312 @@ class ApplicationStats(APIView): authentication_classes = [TokenAuth] @extend_schema( - methods=['GET'], - description=_('Dialogue-related statistical trends'), - summary=_('Dialogue-related statistical trends'), - operation_id=_('Dialogue-related statistical trends'), # type: ignore + methods=["GET"], + description=_("Dialogue-related statistical trends"), + summary=_("Dialogue-related statistical trends"), + operation_id=_("Dialogue-related statistical trends"), # type: ignore parameters=ApplicationStatsAPI.get_parameters(), responses=ApplicationStatsAPI.get_response(), - tags=[_('Application')] # type: ignore + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), ) - @has_permissions(PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_application_permission(), - PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_permission_workspace_manage_role(), - ViewPermission([RoleConstants.USER.get_workspace_role()], - [PermissionConstants.APPLICATION.get_workspace_application_permission()], - compare=CompareConstants.AND), - RoleConstants.WORKSPACE_MANAGE.get_workspace_role()) def get(self, request: Request, workspace_id: str, application_id: str): return result.success( - ApplicationStatisticsSerializer(data={'application_id': application_id, 'workspace_id': workspace_id, - 'start_time': request.query_params.get( - 'start_time'), - 'end_time': request.query_params.get( - 'end_time') - }).get_chat_record_aggregate_trend()) + ApplicationStatisticsSerializer( + data={ + "application_id": application_id, + "workspace_id": workspace_id, + "start_time": request.query_params.get("start_time"), + "end_time": request.query_params.get("end_time"), + } + ).get_chat_record_aggregate_trend() + ) class TokenUsageStatistics(APIView): authentication_classes = [TokenAuth] # 应用的token使用统计 根据人的使用数排序 @extend_schema( - methods=['GET'], - description=_('Application token usage statistics'), - summary=_('Application token usage statistics'), - operation_id=_('Application token usage statistics'), # type: ignore + methods=["GET"], + description=_("Application token usage statistics"), + summary=_("Application token usage statistics"), + operation_id=_("Application token usage statistics"), # type: ignore parameters=ApplicationStatsAPI.get_parameters(), responses=ApplicationStatsAPI.get_response(), - tags=[_('Application')] # type: ignore - ) - @has_permissions(PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_application_permission(), - PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_permission_workspace_manage_role(), - ViewPermission([RoleConstants.USER.get_workspace_role()], - [PermissionConstants.APPLICATION.get_workspace_application_permission()], - compare=CompareConstants.AND), - RoleConstants.WORKSPACE_MANAGE.get_workspace_role()) + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + ) def get(self, request: Request, workspace_id: str, application_id: str): return result.success( - ApplicationStatisticsSerializer(data={'application_id': application_id, 'workspace_id': workspace_id, - 'start_time': request.query_params.get( - 'start_time'), - 'end_time': request.query_params.get( - 'end_time') - }).get_token_usage_statistics()) + ApplicationStatisticsSerializer( + data={ + "application_id": application_id, + "workspace_id": workspace_id, + "start_time": request.query_params.get("start_time"), + "end_time": request.query_params.get("end_time"), + } + ).get_token_usage_statistics() + ) class TopQuestionsStatistics(APIView): authentication_classes = [TokenAuth] + # 应用的top问题统计 @extend_schema( - methods=['GET'], - description=_('Application top question statistics'), - summary=_('Application top question statistics'), - operation_id=_('Application top question statistics'), # type: ignore + methods=["GET"], + description=_("Application top question statistics"), + summary=_("Application top question statistics"), + operation_id=_("Application top question statistics"), # type: ignore + parameters=ApplicationStatsAPI.get_parameters(), + responses=ApplicationStatsAPI.get_response(), + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + ) + def get(self, request: Request, workspace_id: str, application_id: str): + return result.success( + ApplicationStatisticsSerializer( + data={ + "application_id": application_id, + "workspace_id": workspace_id, + "start_time": request.query_params.get("start_time"), + "end_time": request.query_params.get("end_time"), + } + ).get_top_questions_statistics() + ) + + class TokenUsageRanking(APIView): + authentication_classes = [TokenAuth] + + # 应用的token使用分页排行 按用户Tokens降序 + @extend_schema( + methods=["GET"], + description=_("Application token usage ranking"), + summary=_("Application token usage ranking"), + operation_id=_("Application token usage ranking"), # type: ignore + parameters=ApplicationStatsAPI.get_parameters(), + responses=ApplicationStatsAPI.get_response(), + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + ) + def get(self, request: Request, workspace_id: str, application_id: str, current_page: int, page_size: int): + return result.success( + ApplicationStatisticsSerializer( + data={ + "application_id": application_id, + "workspace_id": workspace_id, + "start_time": request.query_params.get("start_time"), + "end_time": request.query_params.get("end_time"), + "name": request.query_params.get("name"), + } + ).get_ranking_statistics("userTokens", current_page, page_size) + ) + + class TopQuestionsRanking(APIView): + authentication_classes = [TokenAuth] + + # 应用的top问题分页排行 按用户对话次数降序 + @extend_schema( + methods=["GET"], + description=_("Application top question ranking"), + summary=_("Application top question ranking"), + operation_id=_("Application top question ranking"), # type: ignore + parameters=ApplicationStatsAPI.get_parameters(), + responses=ApplicationStatsAPI.get_response(), + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + ) + def get(self, request: Request, workspace_id: str, application_id: str, current_page: int, page_size: int): + return result.success( + ApplicationStatisticsSerializer( + data={ + "application_id": application_id, + "workspace_id": workspace_id, + "start_time": request.query_params.get("start_time"), + "end_time": request.query_params.get("end_time"), + "name": request.query_params.get("name"), + } + ).get_ranking_statistics("questions", current_page, page_size) + ) + + class TokensAggregation(APIView): + authentication_classes = [TokenAuth] + + # 应用的token总量 + @extend_schema( + methods=["GET"], + description=_("Application tokens aggregation"), + summary=_("Application tokens aggregation"), + operation_id=_("Application tokens aggregation"), # type: ignore + parameters=ApplicationStatsAPI.get_parameters(), + responses=ApplicationStatsAPI.get_response(), + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + ) + def get(self, request: Request, workspace_id: str, application_id: str): + return result.success( + ApplicationStatisticsSerializer( + data={ + "application_id": application_id, + "workspace_id": workspace_id, + "start_time": request.query_params.get("start_time"), + "end_time": request.query_params.get("end_time"), + } + ).get_tokens_aggregation() + ) + + class ChatRecordAggregation(APIView): + authentication_classes = [TokenAuth] + + # 应用的对话次数总量 + @extend_schema( + methods=["GET"], + description=_("Application chat record aggregation"), + summary=_("Application chat record aggregation"), + operation_id=_("Application chat record aggregation"), # type: ignore parameters=ApplicationStatsAPI.get_parameters(), responses=ApplicationStatsAPI.get_response(), - tags=[_('Application')] # type: ignore - ) - @has_permissions(PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_application_permission(), - PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_permission_workspace_manage_role(), - ViewPermission([RoleConstants.USER.get_workspace_role()], - [PermissionConstants.APPLICATION.get_workspace_application_permission()], - compare=CompareConstants.AND), - RoleConstants.WORKSPACE_MANAGE.get_workspace_role()) + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + ) def get(self, request: Request, workspace_id: str, application_id: str): return result.success( - ApplicationStatisticsSerializer(data={'application_id': application_id, 'workspace_id': workspace_id, - 'start_time': request.query_params.get( - 'start_time'), - 'end_time': request.query_params.get( - 'end_time') - }).get_top_questions_statistics()) + ApplicationStatisticsSerializer( + data={ + "application_id": application_id, + "workspace_id": workspace_id, + "start_time": request.query_params.get("start_time"), + "end_time": request.query_params.get("end_time"), + } + ).get_chat_record_aggregation() + ) + + class TokenUsageExport(APIView): + authentication_classes = [TokenAuth] + + # 应用的token使用排行导出 + @extend_schema( + methods=["GET"], + description=_("Application token usage ranking export"), + summary=_("Application token usage ranking export"), + operation_id=_("Application token usage ranking export"), # type: ignore + parameters=ApplicationStatsAPI.get_parameters(), + responses=ApplicationStatsAPI.get_response(), + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_EXPORT.get_workspace_application_permission(), + PermissionConstants.APPLICATION_EXPORT.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + ) + def get(self, request: Request, workspace_id: str, application_id: str): + return ApplicationStatisticsSerializer( + data={ + "application_id": application_id, + "workspace_id": workspace_id, + "start_time": request.query_params.get("start_time"), + "end_time": request.query_params.get("end_time"), + "name": request.query_params.get("name"), + } + ).export_ranking_statistics("userTokens") + + class TopQuestionsExport(APIView): + authentication_classes = [TokenAuth] + + # 应用的top问题排行导出 + @extend_schema( + methods=["GET"], + description=_("Application top question ranking export"), + summary=_("Application top question ranking export"), + operation_id=_("Application top question ranking export"), # type: ignore + parameters=ApplicationStatsAPI.get_parameters(), + responses=ApplicationStatsAPI.get_response(), + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_EXPORT.get_workspace_application_permission(), + PermissionConstants.APPLICATION_EXPORT.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + ) + def get(self, request: Request, workspace_id: str, application_id: str): + return ApplicationStatisticsSerializer( + data={ + "application_id": application_id, + "workspace_id": workspace_id, + "start_time": request.query_params.get("start_time"), + "end_time": request.query_params.get("end_time"), + "name": request.query_params.get("name"), + } + ).export_ranking_statistics("questions")