diff --git a/api/controllers/admin/__init__.py b/api/controllers/admin/__init__.py index aba299b71b..a77d129c38 100644 --- a/api/controllers/admin/__init__.py +++ b/api/controllers/admin/__init__.py @@ -1,11 +1,10 @@ from flask import Blueprint - from libs.external_api import ExternalApi bp = Blueprint("admin_api", __name__, url_prefix="/admin") api = ExternalApi(bp) from .auth import login +from .settings import settings from .stats import stats -from .students import students -from .settings import settings \ No newline at end of file +from .students import conversation, message, students diff --git a/api/controllers/admin/students/__init__.py b/api/controllers/admin/students/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/controllers/admin/students/conversation.py b/api/controllers/admin/students/conversation.py new file mode 100644 index 0000000000..7299dd08c2 --- /dev/null +++ b/api/controllers/admin/students/conversation.py @@ -0,0 +1,118 @@ +import services +from controllers.admin import api +from controllers.admin.students.error import NotChatAppError +from controllers.admin.wraps import validate_admin_token_and_extract_info +from core.app.entities.app_invoke_entities import InvokeFrom +from extensions.ext_database import db +from fields.conversation_fields import conversation_infinite_scroll_pagination_fields +from flask_restful import Resource, marshal_with, reqparse # type: ignore +from flask_restful.inputs import int_range # type: ignore +from libs.helper import uuid_value +from models.model import App, AppMode +from services.conversation_service import ConversationService +from services.end_user_service import EndUserService +from sqlalchemy.orm import Session # type: ignore +from werkzeug.exceptions import NotFound + + +class StudentConversation(Resource): + @validate_admin_token_and_extract_info + @marshal_with(conversation_infinite_scroll_pagination_fields) + def get(self, app_model: App, student_id: str): + """Get student's conversation history. + --- + tags: + - admin/students + summary: Get student conversation history + description: Get complete conversation history for a specific student + security: + - ApiKeyAuth: [] + parameters: + - name: student_id + in: path + type: string + required: true + description: ID of the student + - name: start_time + in: query + type: string + format: date-time + description: Filter conversations after this time + - name: end_time + in: query + type: string + format: date-time + description: Filter conversations before this time + - name: page + in: query + type: integer + default: 1 + description: Page number + - name: per_page + in: query + type: integer + default: 50 + description: Conversations per page + responses: + 200: + description: Conversation history retrieved successfully + schema: + type: object + properties: + total: + type: integer + conversations: + type: array + items: + type: object + properties: + timestamp: + type: string + format: date-time + role: + type: string + enum: [user, assistant] + content: + type: string + 401: + description: Invalid or missing API key + 404: + description: Student not found + """ + end_user = EndUserService.load_end_user_by_id(student_id) + if not end_user: + raise NotFound("Student not found") + + app_mode = AppMode.value_of(app_model.mode) + if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: + raise NotChatAppError() + + parser = reqparse.RequestParser() + parser.add_argument("last_id", type=uuid_value, location="args") + parser.add_argument("limit", type=int_range(1, 100), required=False, default=20, location="args") + parser.add_argument( + "sort_by", + type=str, + choices=["created_at", "-created_at", "updated_at", "-updated_at"], + required=False, + default="-updated_at", + location="args", + ) + args = parser.parse_args() + + try: + with Session(db.engine) as session: + return ConversationService.pagination_by_last_id( + session=session, + app_model=app_model, + user=end_user, + last_id=args["last_id"], + limit=args["limit"], + invoke_from=InvokeFrom.SERVICE_API, + sort_by=args["sort_by"], + ) + except services.errors.conversation.LastConversationNotExistsError: + raise NotFound("Last Conversation Not Exists.") + + +api.add_resource(StudentConversation, '/students//conversation') diff --git a/api/controllers/admin/students/error.py b/api/controllers/admin/students/error.py new file mode 100644 index 0000000000..ca91da80c1 --- /dev/null +++ b/api/controllers/admin/students/error.py @@ -0,0 +1,109 @@ +from libs.exception import BaseHTTPException + + +class AppUnavailableError(BaseHTTPException): + error_code = "app_unavailable" + description = "App unavailable, please check your app configurations." + code = 400 + + +class NotCompletionAppError(BaseHTTPException): + error_code = "not_completion_app" + description = "Please check if your Completion app mode matches the right API route." + code = 400 + + +class NotChatAppError(BaseHTTPException): + error_code = "not_chat_app" + description = "Please check if your app mode matches the right API route." + code = 400 + + +class NotWorkflowAppError(BaseHTTPException): + error_code = "not_workflow_app" + description = "Please check if your app mode matches the right API route." + code = 400 + + +class ConversationCompletedError(BaseHTTPException): + error_code = "conversation_completed" + description = "The conversation has ended. Please start a new conversation." + code = 400 + + +class ProviderNotInitializeError(BaseHTTPException): + error_code = "provider_not_initialize" + description = ( + "No valid model provider credentials found. " + "Please go to Settings -> Model Provider to complete your provider credentials." + ) + code = 400 + + +class ProviderQuotaExceededError(BaseHTTPException): + error_code = "provider_quota_exceeded" + description = ( + "Your quota for Dify Hosted OpenAI has been exhausted. " + "Please go to Settings -> Model Provider to complete your own provider credentials." + ) + code = 400 + + +class ProviderModelCurrentlyNotSupportError(BaseHTTPException): + error_code = "model_currently_not_support" + description = "Dify Hosted OpenAI trial currently not support the GPT-4 model." + code = 400 + + +class CompletionRequestError(BaseHTTPException): + error_code = "completion_request_error" + description = "Completion request failed." + code = 400 + + +class NoAudioUploadedError(BaseHTTPException): + error_code = "no_audio_uploaded" + description = "Please upload your audio." + code = 400 + + +class AudioTooLargeError(BaseHTTPException): + error_code = "audio_too_large" + description = "Audio size exceeded. {message}" + code = 413 + + +class UnsupportedAudioTypeError(BaseHTTPException): + error_code = "unsupported_audio_type" + description = "Audio type not allowed." + code = 415 + + +class ProviderNotSupportSpeechToTextError(BaseHTTPException): + error_code = "provider_not_support_speech_to_text" + description = "Provider not support speech to text." + code = 400 + + +class NoFileUploadedError(BaseHTTPException): + error_code = "no_file_uploaded" + description = "Please upload your file." + code = 400 + + +class TooManyFilesError(BaseHTTPException): + error_code = "too_many_files" + description = "Only one file is allowed." + code = 400 + + +class FileTooLargeError(BaseHTTPException): + error_code = "file_too_large" + description = "File size exceeded. {message}" + code = 413 + + +class UnsupportedFileTypeError(BaseHTTPException): + error_code = "unsupported_file_type" + description = "File type not allowed." + code = 415 diff --git a/api/controllers/admin/students/message.py b/api/controllers/admin/students/message.py new file mode 100644 index 0000000000..9034c10606 --- /dev/null +++ b/api/controllers/admin/students/message.py @@ -0,0 +1,155 @@ +import services +from controllers.admin import api +from controllers.admin.students.error import NotChatAppError +from controllers.admin.wraps import validate_admin_token_and_extract_info +from fields.conversation_fields import message_file_fields +from fields.raws import FilesContainedField +from flask_restful import Resource, fields, marshal_with, reqparse # type: ignore +from flask_restful.inputs import int_range # type: ignore +from libs.helper import TimestampField, uuid_value +from models.model import App, AppMode, EndUser +from services.end_user_service import EndUserService +from services.errors.message import SuggestedQuestionsAfterAnswerDisabledError +from services.message_service import MessageService +from werkzeug.exceptions import NotFound + + +class MessageListApi(Resource): + feedback_fields = {"rating": fields.String} + retriever_resource_fields = { + "id": fields.String, + "message_id": fields.String, + "position": fields.Integer, + "dataset_id": fields.String, + "dataset_name": fields.String, + "document_id": fields.String, + "document_name": fields.String, + "data_source_type": fields.String, + "segment_id": fields.String, + "score": fields.Float, + "hit_count": fields.Integer, + "word_count": fields.Integer, + "segment_position": fields.Integer, + "index_node_hash": fields.String, + "content": fields.String, + "created_at": TimestampField, + } + + agent_thought_fields = { + "id": fields.String, + "chain_id": fields.String, + "message_id": fields.String, + "position": fields.Integer, + "thought": fields.String, + "tool": fields.String, + "tool_labels": fields.Raw, + "tool_input": fields.String, + "created_at": TimestampField, + "observation": fields.String, + "message_files": fields.List(fields.Nested(message_file_fields)), + } + + message_fields = { + "id": fields.String, + "conversation_id": fields.String, + "parent_message_id": fields.String, + "inputs": FilesContainedField, + "query": fields.String, + "answer": fields.String(attribute="re_sign_file_url_answer"), + "message_files": fields.List(fields.Nested(message_file_fields)), + "feedback": fields.Nested(feedback_fields, attribute="user_feedback", allow_null=True), + "retriever_resources": fields.List(fields.Nested(retriever_resource_fields)), + "created_at": TimestampField, + "agent_thoughts": fields.List(fields.Nested(agent_thought_fields)), + "status": fields.String, + "error": fields.String, + } + + message_infinite_scroll_pagination_fields = { + "limit": fields.Integer, + "has_more": fields.Boolean, + "data": fields.List(fields.Nested(message_fields)), + } + + @validate_admin_token_and_extract_info + @marshal_with(message_infinite_scroll_pagination_fields) + def get(self, app_model: App, student_id: str): + """Get messages list. + --- + tags: + - admin/students + summary: List messages + description: Get a paginated list of messages for a conversation + security: + - ApiKeyAuth: [] + parameters: + - name: student_id + in: path + required: true + type: string + format: uuid + description: ID of the student to get messages for + - name: conversation_id + in: query + required: true + type: string + format: uuid + description: ID of the conversation to get messages for + - name: first_id + in: query + type: string + format: uuid + description: ID of the first message for pagination + - name: limit + in: query + type: integer + minimum: 1 + maximum: 100 + default: 20 + description: Number of messages to return + responses: + 200: + description: Messages retrieved successfully + schema: + type: object + properties: + limit: + type: integer + has_more: + type: boolean + data: + type: array + items: + type: object + 400: + description: Invalid request + 401: + description: Invalid or missing token + 404: + description: Conversation not found or not a chat app + """ + app_mode = AppMode.value_of(app_model.mode) + if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: + raise NotChatAppError() + + end_user = EndUserService.load_end_user_by_id(student_id) + if not end_user: + raise NotFound("Student not found") + + parser = reqparse.RequestParser() + parser.add_argument("conversation_id", required=True, type=uuid_value, location="args") + parser.add_argument("first_id", type=uuid_value, location="args") + parser.add_argument("limit", type=int_range(1, 100), required=False, default=20, location="args") + args = parser.parse_args() + + try: + return MessageService.pagination_by_first_id( + app_model, end_user, args["conversation_id"], args["first_id"], args["limit"] + ) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + except services.errors.message.FirstMessageNotExistsError: + raise NotFound("First Message Not Exists.") + + +api.add_resource(MessageListApi, "/students//messages") diff --git a/api/controllers/admin/students/students.py b/api/controllers/admin/students/students.py index 95734cb740..0ef0c96830 100644 --- a/api/controllers/admin/students/students.py +++ b/api/controllers/admin/students/students.py @@ -1,13 +1,15 @@ from controllers.admin import api from controllers.admin.wraps import validate_admin_token_and_extract_info +from fields.end_user_fields import end_users_infinite_scroll_pagination_fields from flask import Blueprint -from flask_restful import Api, Resource # type: ignore +from flask_restful import Api, Resource, marshal_with # type: ignore from models.model import App from services.end_user_service import EndUserService class StudentList(Resource): @validate_admin_token_and_extract_info + @marshal_with(end_users_infinite_scroll_pagination_fields) def get(self, app_model: App): """Get all end_user list related with the app_model with filters with pagination. --- @@ -131,76 +133,7 @@ class StudentList(Resource): # Get students with pagination offset = (page - 1) * limit - # Query students from database (implementation will depend on your ORM/database structure) - data = EndUserService.get_user_list(app_model=app_model, filters=filters, offset=offset, limit=limit) - - return {"result": "success", "data": data}, 200 - - -class StudentConversation(Resource): - @validate_admin_token_and_extract_info - def get(self, app_model: App): - """Get student's conversation history. - --- - tags: - - admin/students - summary: Get student conversation history - description: Get complete conversation history for a specific student - security: - - ApiKeyAuth: [] - parameters: - - name: student_id - in: path - type: string - required: true - description: ID of the student - - name: start_time - in: query - type: string - format: date-time - description: Filter conversations after this time - - name: end_time - in: query - type: string - format: date-time - description: Filter conversations before this time - - name: page - in: query - type: integer - default: 1 - description: Page number - - name: per_page - in: query - type: integer - default: 50 - description: Conversations per page - responses: - 200: - description: Conversation history retrieved successfully - schema: - type: object - properties: - total: - type: integer - conversations: - type: array - items: - type: object - properties: - timestamp: - type: string - format: date-time - role: - type: string - enum: [user, assistant] - content: - type: string - 401: - description: Invalid or missing API key - 404: - description: Student not found - """ - pass + return EndUserService.pagination_by_filters(app_model=app_model, filters=filters, offset=offset, limit=limit) class StudentAnalysis(Resource): @@ -443,7 +376,6 @@ class StudentNote(Resource): api.add_resource(StudentList, '/students') -api.add_resource(StudentConversation, '/students//conversation') -api.add_resource(StudentAnalysis, '/students//analysis') -api.add_resource(StudentStatus, '/students//status') -api.add_resource(StudentNote, '/students//note') +# api.add_resource(StudentAnalysis, '/students//analysis') +# api.add_resource(StudentStatus, '/students//status') +# api.add_resource(StudentNote, '/students//note') diff --git a/api/fields/conversation_fields.py b/api/fields/conversation_fields.py index c54554a6de..eb7884a904 100644 --- a/api/fields/conversation_fields.py +++ b/api/fields/conversation_fields.py @@ -1,6 +1,5 @@ -from flask_restful import fields # type: ignore - from fields.member_fields import simple_account_fields +from flask_restful import fields # type: ignore from libs.helper import TimestampField from .raws import FilesContainedField diff --git a/api/fields/end_user_fields.py b/api/fields/end_user_fields.py index aefa0b2758..973d54122d 100644 --- a/api/fields/end_user_fields.py +++ b/api/fields/end_user_fields.py @@ -1,4 +1,5 @@ from flask_restful import fields # type: ignore +from libs.helper import TimestampField simple_end_user_fields = { "id": fields.String, @@ -6,3 +7,23 @@ simple_end_user_fields = { "is_anonymous": fields.Boolean, "session_id": fields.String, } + +detailed_end_user_fields = { + "id": fields.String, + "name": fields.String, + "email": fields.String, + "first_chat_at": TimestampField, + "last_chat_at": TimestampField, + "total_messages": fields.Integer, + "active_days": fields.Integer, + "health_status": fields.String, + "topics": fields.String, + "summary": fields.String, + "major": fields.String, +} + +end_users_infinite_scroll_pagination_fields = { + "limit": fields.Integer, + "has_more": fields.Boolean, + "data": fields.List(fields.Nested(detailed_end_user_fields)), +} diff --git a/api/services/end_user_service.py b/api/services/end_user_service.py index 79fcf93335..46479a6ab7 100644 --- a/api/services/end_user_service.py +++ b/api/services/end_user_service.py @@ -1,14 +1,16 @@ from typing import Any, Dict, Optional, Tuple from extensions.ext_database import db -from models.account import Account, TenantAccountJoin +from libs.infinite_scroll_pagination import InfiniteScrollPagination from models.model import App, Conversation, EndUser, Message from sqlalchemy import and_, desc, func class EndUserService: @staticmethod - def get_user_list(app_model: App, filters: Dict[str, Any], offset: int, limit: int) -> Dict[str, Any]: + def pagination_by_filters( + app_model: App, filters: Dict[str, Any], offset: int, limit: int + ) -> InfiniteScrollPagination: """ Get a list of end users with filtering and pagination @@ -98,7 +100,7 @@ class EndUserService: # Convert to dictionary for JSON serialization end_user_dict = { - 'id': end_user.id, + 'id': end_user.external_user_id, 'email': end_user.email, 'first_chat_at': end_user.first_chat_at, 'last_chat_at': end_user.last_chat_at, @@ -113,7 +115,11 @@ class EndUserService: users.append(end_user_dict) # Format and return results - return {'total': total_count, 'users': users} + return InfiniteScrollPagination(data=users, limit=limit, has_more=total_count > offset + limit) + + @staticmethod + def load_end_user_by_id(end_user_id: str) -> EndUser: + return db.session.query(EndUser).filter(EndUser.external_user_id == end_user_id).first() @staticmethod def get_user_profile(end_user_id: str) -> Dict[str, Any]: