import logging from http import HTTPStatus from typing import Annotated from uuid import UUID from flask_restx import Resource from pydantic import BaseModel, Field, WithJsonSchema from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery from controllers.common.errors import InternalServerError, MessageFeedbackRatingRequiredError, NotFoundError from controllers.common.fields import SimpleResultStringListResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.app.error import ( AppSuggestedQuestionsAfterAnswerDisabledError, AppUnavailableError, CompletionRequestError, NotChatAppError, ProviderModelCurrentlyNotSupportError, ProviderNotInitializeError, ProviderQuotaExceededError, ) from controllers.service_api.flask_admission import service_api_app_admission, service_api_end_user_admission from controllers.service_api.schema import expect_with_user from controllers.service_api.wraps import FetchUserArg, WhereisUserArg from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from extensions.ext_application_services import application_services from fields.base import ResponseModel from fields.conversation_fields import ResultResponse from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem from graphon.model_runtime.errors.invoke import InvokeError from libs.external_api import error_responses from libs.helper import dump_response from machinery.context import AppRequestContext, ServiceApiEndUserContext from models.enums import FeedbackRating from models.model import AppMode from services.agent.errors import AgentVersionNotFoundError from services.app_definition_query_service import AppDefinitionUnavailableError from services.entities.message_entities import MessageEndUser from services.errors.conversation import ConversationNotExistsError from services.errors.message import ( FeedbackRatingRequiredError, FirstMessageNotExistsError, MessageActorNotFoundError, MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError, ) logger = logging.getLogger(__name__) UUIDString = Annotated[str, WithJsonSchema({"format": "uuid", "type": "string"})] class FeedbackListQuery(BaseModel): page: int = Field(default=1, ge=1, description="Page number for pagination.") limit: int = Field(default=20, ge=1, le=101, description="Number of records per page.") class AppFeedbackResponse(ResponseModel): id: UUIDString app_id: UUIDString conversation_id: UUIDString message_id: UUIDString rating: str content: str | None = None from_source: str from_end_user_id: UUIDString | None = None from_account_id: UUIDString | None = None created_at: str updated_at: str class AppFeedbackListResponse(ResponseModel): data: list[AppFeedbackResponse] register_schema_models(service_api_ns, MessageListQuery, MessageFeedbackPayload, FeedbackListQuery) register_response_schema_models( service_api_ns, ResultResponse, SimpleResultStringListResponse, MessageInfiniteScrollPagination, MessageListItem, AppFeedbackListResponse, ) @service_api_ns.route("/messages") class MessageListApi(Resource): @error_responses(AppUnavailableError, NotChatAppError) @service_api_ns.doc("list_messages") @service_api_ns.doc( summary="List Conversation Messages", description=( "Returns historical chat records in a scrolling load format, with the first page returning " "the latest `limit` messages, i.e., in reverse order." ), tags=["Conversations"], responses={ HTTPStatus.OK: "Successfully retrieved conversation history.", HTTPStatus.BAD_REQUEST: ( "`not_chat_app` : App mode does not match the API route. " "`app_unavailable` : App is no longer available." ), HTTPStatus.NOT_FOUND: ( "- `not_found` : Conversation does not exist.\n- `not_found` : First message does not exist." ), }, ) @service_api_ns.doc(params=query_params_from_model(MessageListQuery)) @service_api_ns.doc(description="List messages in a conversation") @service_api_ns.doc( responses={ HTTPStatus.OK: "Messages retrieved successfully", HTTPStatus.BAD_REQUEST: ( "`not_chat_app` : App mode does not match the API route. " "`app_unavailable` : App is no longer available." ), HTTPStatus.UNAUTHORIZED: "Unauthorized - invalid API token", HTTPStatus.NOT_FOUND: "Conversation or first message not found", } ) @service_api_ns.response( HTTPStatus.OK, "Messages retrieved successfully", service_api_ns.models[MessageInfiniteScrollPagination.__name__], ) @service_api_end_user_admission(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY)) @model_validate(MessageListQuery) def get(self, query_args: MessageListQuery, context: ServiceApiEndUserContext) -> dict[str, object]: """List messages in a conversation. Retrieves messages with pagination support using first_id. """ app_mode = context.app_mode if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}: raise NotChatAppError() try: pagination = application_services().message_queries.get_page( app_id=context.app_id, app_owner_tenant_id=context.tenant_id, actor=MessageEndUser(end_user_id=context.end_user_id), conversation_id=query_args.conversation_id, first_id=query_args.first_id or None, limit=query_args.limit, ) return dump_response(MessageInfiniteScrollPagination, pagination) except AppDefinitionUnavailableError as error: raise AppUnavailableError() from error except MessageActorNotFoundError as error: raise NotFoundError("End user not found") from error except ConversationNotExistsError as error: raise NotFoundError("Conversation Not Exists.") from error except FirstMessageNotExistsError as error: raise NotFoundError("First Message Not Exists.") from error @service_api_ns.route("/messages//feedbacks") class MessageFeedbackApi(Resource): @error_responses(AppUnavailableError, MessageFeedbackRatingRequiredError) @expect_with_user(service_api_ns, MessageFeedbackPayload) @service_api_ns.response( HTTPStatus.OK, "Feedback submitted successfully", service_api_ns.models[ResultResponse.__name__] ) @service_api_ns.doc("create_message_feedback") @service_api_ns.doc( summary="Submit Message Feedback", description=( "Submit feedback for a message. End users can rate messages as `like` or `dislike`, and " "optionally provide text feedback. Pass `null` for `rating` to revoke previously submitted feedback." ), tags=["Feedback"], responses={ HTTPStatus.NOT_FOUND: "`not_found` : Message does not exist.", }, ) @service_api_ns.doc(description="Submit feedback for a message") @service_api_ns.doc(params={"message_id": "Message ID."}) @service_api_ns.doc( responses={ HTTPStatus.OK: "Feedback submitted successfully", HTTPStatus.BAD_REQUEST: ( "`message_feedback_rating_required`: Cannot revoke feedback that does not exist. " "`app_unavailable`: App is no longer available." ), HTTPStatus.UNAUTHORIZED: "Unauthorized - invalid API token", HTTPStatus.NOT_FOUND: "Message not found", } ) @service_api_end_user_admission(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True)) @model_validate(MessageFeedbackPayload) def post( self, payload: MessageFeedbackPayload, context: ServiceApiEndUserContext, message_id: UUID, ) -> dict[str, object]: """Submit feedback for a message. Allows users to rate messages as like/dislike and provide optional feedback content. """ message_id_str = str(message_id) try: application_services().message_feedbacks.set_feedback( app_id=context.app_id, app_owner_tenant_id=context.tenant_id, message_id=message_id_str, actor=MessageEndUser(end_user_id=context.end_user_id), rating=FeedbackRating(payload.rating) if payload.rating else None, content=payload.content, ) except AppDefinitionUnavailableError as error: raise AppUnavailableError() from error except MessageActorNotFoundError as error: raise NotFoundError("End user not found") from error except MessageNotExistsError as error: raise NotFoundError("Message Not Exists.") from error except FeedbackRatingRequiredError as error: raise MessageFeedbackRatingRequiredError() from error return dump_response(ResultResponse, {"result": "success"}) @service_api_ns.route("/app/feedbacks") class AppGetFeedbacksApi(Resource): @error_responses(AppUnavailableError) @service_api_ns.doc("get_app_feedbacks") @service_api_ns.doc( summary="List App Feedbacks", description=( "Retrieve a paginated list of all feedback submitted for messages in this application, including both " "end-user and admin feedback." ), tags=["Feedback"], responses={ HTTPStatus.OK: "A list of application feedbacks.", }, ) @service_api_ns.doc(params=query_params_from_model(FeedbackListQuery)) @service_api_ns.doc(description="Get all feedbacks for the application") @service_api_ns.doc( responses={ HTTPStatus.OK: "Feedbacks retrieved successfully", HTTPStatus.BAD_REQUEST: "`app_unavailable`: App is no longer available.", HTTPStatus.UNAUTHORIZED: "Unauthorized - invalid API token", } ) @service_api_ns.response( HTTPStatus.OK, "Feedbacks retrieved successfully", service_api_ns.models[AppFeedbackListResponse.__name__], ) @service_api_app_admission @model_validate(FeedbackListQuery) def get(self, query_args: FeedbackListQuery, context: AppRequestContext) -> dict[str, object]: """Get all feedbacks for the application. Returns paginated list of all feedback submitted for messages in this app. """ try: feedbacks = application_services().message_feedbacks.get_feedbacks( app_id=context.app_id, app_owner_tenant_id=context.tenant_id, page=query_args.page, limit=query_args.limit, ) except AppDefinitionUnavailableError as error: raise AppUnavailableError() from error return dump_response(AppFeedbackListResponse, {"data": feedbacks}) @service_api_ns.route("/messages//suggested") class MessageSuggestedApi(Resource): @error_responses( AgentVersionNotFoundError, AppSuggestedQuestionsAfterAnswerDisabledError, AppUnavailableError, CompletionRequestError, NotChatAppError, ProviderModelCurrentlyNotSupportError, ProviderNotInitializeError, ProviderQuotaExceededError, ) @service_api_ns.doc("get_suggested_questions") @service_api_ns.doc( summary="Get Next Suggested Questions", description=( "Get next question suggestions for the current message. " "If no usable model can be resolved or the model call to generate questions fails, " "the response is HTTP 200 with an empty data list. " "Model invocation failures during history token counting instead return " "HTTP 400 with `completion_request_error`." ), tags=["Chats", "Chatflows"], responses={ HTTPStatus.OK: "Suggested questions retrieved successfully", HTTPStatus.BAD_REQUEST: ( "- `not_chat_app` : App mode does not match the API route.\n" "- `app_unavailable` : App is no longer available.\n" "- `completion_request_error` : Model invocation failed while counting history tokens." ), HTTPStatus.UNAUTHORIZED: "Unauthorized - invalid API token", HTTPStatus.FORBIDDEN: ( "- `forbidden` : Token scope does not allow access.\n" "- `app_not_found` : The token's app no longer exists.\n" "- `app_abnormal_status` : App status does not allow API access.\n" "- `app_api_disabled` : The app's API service has been disabled.\n" "- `workspace_not_found` : The app's workspace no longer exists.\n" "- `workspace_archived` : The app's workspace is archived.\n" "- `app_suggested_questions_after_answer_disabled` : Suggested questions feature is disabled." ), HTTPStatus.NOT_FOUND: ( "- `not_found` : End user, message, or conversation does not exist.\n" "- `agent_version_not_found_error` : Agent config version does not exist." ), HTTPStatus.INTERNAL_SERVER_ERROR: "`internal_server_error` : Internal server error.", }, ) @service_api_ns.response( HTTPStatus.OK, "Suggested questions retrieved successfully", service_api_ns.models[SimpleResultStringListResponse.__name__], ) @service_api_ns.doc(params={"message_id": "Message ID"}) @service_api_end_user_admission(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY, required=True)) def get(self, context: ServiceApiEndUserContext, message_id: UUID) -> dict[str, object]: """Get suggested follow-up questions for a message. Returns an empty list when model resolution or the question-generation model call fails. History token-counting invocation failures return HTTP 400 with completion_request_error. """ message_id_str = str(message_id) app_mode = AppMode.value_of(context.app_mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}: raise NotChatAppError() try: questions = application_services().message_suggested_questions.get_suggested_questions( app_id=context.app_id, app_owner_tenant_id=context.tenant_id, expected_app_mode=context.app_mode, actor=MessageEndUser(end_user_id=context.end_user_id), invoke_from="service-api", message_id=message_id_str, ) except AppDefinitionUnavailableError: raise AppUnavailableError() from None except MessageActorNotFoundError: raise NotFoundError("End user not found") from None except MessageNotExistsError as caught_error: raise NotFoundError("Message Not Exists.") from caught_error except ConversationNotExistsError: raise NotFoundError("Conversation not found") from None except SuggestedQuestionsAfterAnswerDisabledError: raise AppSuggestedQuestionsAfterAnswerDisabledError() from None except ProviderTokenNotInitError as error: raise ProviderNotInitializeError(error.description) from error except QuotaExceededError: raise ProviderQuotaExceededError() from None except ModelCurrentlyNotSupportError: raise ProviderModelCurrentlyNotSupportError() from None except InvokeError as error: raise CompletionRequestError(error.description) from error except AgentVersionNotFoundError: # The legacy Agent config reader still owns this HTTP error. # Remove this compatibility case when it exposes a domain error. raise except Exception as caught_error: logger.exception("internal server error.") raise InternalServerError() from caught_error return dump_response(SimpleResultStringListResponse, {"result": "success", "data": questions})