mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
refactor(api): unify message suggested questions across API surfaces (#43440)
This commit is contained in:
64 files changed
+5900
-4011
No files matched your search
+26
-1
@@ -999,7 +999,7 @@ forbidden_modules =
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:message-suggested-questions-boundary]
|
||||
name = Message suggested questions port is framework and persistence neutral
|
||||
name = Message suggested questions use case is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.message_suggested_questions_service
|
||||
@@ -1013,6 +1013,31 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:message-suggested-questions-legacy-boundary]
|
||||
name = Suggested questions do not return to the legacy message or generation service
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.message_suggested_questions_service
|
||||
services.message_suggested_questions_queries
|
||||
services.message_suggested_questions_generator
|
||||
repositories.message_repository
|
||||
forbidden_modules =
|
||||
services.message_service
|
||||
services.app_generate_service
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:message-suggested-questions-persistence-boundary]
|
||||
name = Suggested questions persistence does not call concrete configuration services
|
||||
type = forbidden
|
||||
source_modules =
|
||||
repositories.message_repository
|
||||
forbidden_modules =
|
||||
services.conversation_service
|
||||
services.workflow_service
|
||||
services.agent.runtime_config_service
|
||||
services.agent.roster_service
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:billing-application-boundary]
|
||||
name = Billing application services and ports are framework and infrastructure neutral
|
||||
type = forbidden
|
||||
|
||||
@@ -6,9 +6,11 @@ from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import exists, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import HTTPException, InternalServerError, NotFound, Unauthorized
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
|
||||
from controllers.common.errors import InternalServerError as InternalServerHTTPError
|
||||
from controllers.common.errors import NotFoundError, UnauthorizedError
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.rbac import AgentId, PlainApp, RBACCheck
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
@@ -60,10 +62,14 @@ from services.app.agent_app_contracts import AgentAppNotFoundError
|
||||
from services.app.console_service import ConsoleAppNotFoundError
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.conversation_service import ConversationService
|
||||
from services.entities.message_entities import MessageAccount
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
|
||||
from services.errors.message import (
|
||||
MessageActorNotFoundError,
|
||||
MessageNotExistsError,
|
||||
SuggestedQuestionsAfterAnswerDisabledError,
|
||||
)
|
||||
from services.message_service import attach_message_extra_contents
|
||||
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -278,6 +284,7 @@ class MessageAnnotationCountApi(Resource):
|
||||
@console_ns.route("/apps/<uuid:app_id>/chat-messages/<uuid:message_id>/suggested-questions")
|
||||
class MessageSuggestedQuestionApi(Resource):
|
||||
@error_responses(
|
||||
AgentVersionNotFoundError,
|
||||
AppNotFoundError,
|
||||
AppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
AppUnavailableError,
|
||||
@@ -522,17 +529,18 @@ def _get_message_suggested_questions(
|
||||
app_id=app_id,
|
||||
app_owner_tenant_id=context.active_workspace_id,
|
||||
expected_app_mode=app_mode,
|
||||
actor=SuggestedQuestionsAccount(account_id=context.account_id, invoke_from="debugger"),
|
||||
actor=MessageAccount(account_id=context.account_id),
|
||||
invoke_from="debugger",
|
||||
message_id=str(message_id),
|
||||
)
|
||||
except AppDefinitionUnavailableError as exc:
|
||||
raise AppUnavailableError() from exc
|
||||
except SuggestedQuestionsActorNotFoundError as exc:
|
||||
raise Unauthorized("Account no longer exists") from exc
|
||||
except MessageActorNotFoundError as exc:
|
||||
raise UnauthorizedError("Account no longer exists") from exc
|
||||
except MessageNotExistsError as caught_error:
|
||||
raise NotFound("Message not found") from caught_error
|
||||
raise NotFoundError("Message not found") from caught_error
|
||||
except ConversationNotExistsError as caught_error:
|
||||
raise NotFound("Conversation not found") from caught_error
|
||||
raise NotFoundError("Conversation not found") from caught_error
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description) from ex
|
||||
except QuotaExceededError as caught_error:
|
||||
@@ -543,11 +551,13 @@ def _get_message_suggested_questions(
|
||||
raise CompletionRequestError(e.description) from e
|
||||
except SuggestedQuestionsAfterAnswerDisabledError as caught_error:
|
||||
raise AppSuggestedQuestionsAfterAnswerDisabledError() from caught_error
|
||||
except HTTPException:
|
||||
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
|
||||
raise InternalServerHTTPError() from caught_error
|
||||
|
||||
return dump_response(SuggestedQuestionsResponse, {"data": questions})
|
||||
|
||||
|
||||
@@ -47,18 +47,19 @@ from libs.external_api import error_responses
|
||||
from machinery.context import RequestContext
|
||||
from services.account_errors import AccountNotFoundError
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.entities.message_entities import MessageAccount
|
||||
from services.errors.app import MoreLikeThisDisabledError
|
||||
from services.errors.base import BaseServiceError
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import (
|
||||
FirstMessageNotExistsError,
|
||||
MessageActorNotFoundError,
|
||||
MessageNotExistsError,
|
||||
SuggestedQuestionsAfterAnswerDisabledError,
|
||||
)
|
||||
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
|
||||
from services.installed_app_generation_service import InstalledAppNotCompletionError
|
||||
from services.installed_app_message_service import FeedbackRatingRequiredError, MessageNotChatAppError
|
||||
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,7 +86,7 @@ def _message_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
raise InstalledAppNotFoundHTTPError() from error
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except (AccountNotFoundError, SuggestedQuestionsActorNotFoundError) as error:
|
||||
except (AccountNotFoundError, MessageActorNotFoundError) as error:
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except MessageNotChatAppError as error:
|
||||
raise NotChatAppError() from error
|
||||
@@ -243,7 +244,8 @@ class MessageSuggestedQuestionApi(Resource):
|
||||
app_id=installed_app.app_id,
|
||||
app_owner_tenant_id=installed_app.app_owner_tenant_id,
|
||||
expected_app_mode=installed_app.app_mode,
|
||||
actor=SuggestedQuestionsAccount(account_id=request_context.account_id, invoke_from="explore"),
|
||||
actor=MessageAccount(account_id=request_context.account_id),
|
||||
invoke_from="explore",
|
||||
message_id=str(message_id),
|
||||
)
|
||||
except (
|
||||
|
||||
@@ -120,6 +120,7 @@ from services.app_preview_query_service import (
|
||||
AppPreviewUnavailableError,
|
||||
)
|
||||
from services.audio_types import AudioAppRef, AudioUpload
|
||||
from services.entities.message_entities import MessageAccount
|
||||
from services.errors.audio import (
|
||||
AudioTooLargeServiceError,
|
||||
NoAudioUploadedServiceError,
|
||||
@@ -131,11 +132,11 @@ from services.errors.audio import (
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.errors.message import (
|
||||
MessageActorNotFoundError,
|
||||
MessageNotExistsError,
|
||||
SuggestedQuestionsAfterAnswerDisabledError,
|
||||
)
|
||||
from services.file_service import FileUploadActor
|
||||
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
|
||||
from services.trial_app_access_service import TrialAppRef
|
||||
from services.trial_app_generation_service import (
|
||||
TrialAppNotChatError,
|
||||
@@ -732,6 +733,7 @@ class TrialChatTaskStopApi(Resource):
|
||||
)
|
||||
class TrialMessageSuggestedQuestionApi(Resource):
|
||||
@error_responses(
|
||||
AgentVersionNotFoundHTTPError,
|
||||
AppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
@@ -752,12 +754,13 @@ class TrialMessageSuggestedQuestionApi(Resource):
|
||||
app_id=trial_app.app_id,
|
||||
app_owner_tenant_id=trial_app.tenant_id,
|
||||
expected_app_mode=trial_app.app_mode,
|
||||
actor=SuggestedQuestionsAccount(account_id=request_context.account_id, invoke_from="explore"),
|
||||
actor=MessageAccount(account_id=request_context.account_id),
|
||||
invoke_from="explore",
|
||||
message_id=str(message_id),
|
||||
)
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except SuggestedQuestionsActorNotFoundError as error:
|
||||
except MessageActorNotFoundError as error:
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except MessageNotExistsError as caught_error:
|
||||
raise NotFoundError("Message not found") from caught_error
|
||||
@@ -773,6 +776,8 @@ class TrialMessageSuggestedQuestionApi(Resource):
|
||||
raise ProviderModelCurrentlyNotSupportError() from caught_error
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description) from e
|
||||
except AgentVersionNotFoundError as error:
|
||||
raise AgentVersionNotFoundHTTPError() from error
|
||||
except Exception as caught_error:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError() from caught_error
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.errors.app import (
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE,
|
||||
@@ -5,6 +7,36 @@ from services.errors.app import (
|
||||
)
|
||||
|
||||
|
||||
class AppNotFoundError(BaseHTTPException):
|
||||
error_code = "app_not_found"
|
||||
description = "The app no longer exists."
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
class AppAbnormalStatusError(BaseHTTPException):
|
||||
error_code = "app_abnormal_status"
|
||||
description = "The app's status is abnormal."
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
class AppApiDisabledError(BaseHTTPException):
|
||||
error_code = "app_api_disabled"
|
||||
description = "The app's API service has been disabled."
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
class WorkspaceNotFoundError(BaseHTTPException):
|
||||
error_code = "workspace_not_found"
|
||||
description = "Tenant does not exist."
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
class WorkspaceArchivedError(BaseHTTPException):
|
||||
error_code = "workspace_archived"
|
||||
description = "The workspace's status is archived."
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
class AppUnavailableError(BaseHTTPException):
|
||||
error_code = "app_unavailable"
|
||||
description = "App unavailable, please check your app configurations."
|
||||
@@ -83,6 +115,12 @@ class CompletionRequestError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class AppSuggestedQuestionsAfterAnswerDisabledError(BaseHTTPException):
|
||||
error_code = "app_suggested_questions_after_answer_disabled"
|
||||
description = "The 'Suggested Questions After Answer' feature is disabled."
|
||||
code = 403
|
||||
|
||||
|
||||
class NoAudioUploadedError(BaseHTTPException):
|
||||
error_code = "no_audio_uploaded"
|
||||
description = "Please upload your audio."
|
||||
|
||||
@@ -1,30 +1,49 @@
|
||||
import logging
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, TypeAdapter, WithJsonSchema
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
|
||||
from controllers.common.errors import InternalServerError, 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 NotChatAppError
|
||||
from controllers.service_api.app.error import (
|
||||
AppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
NotChatAppError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
)
|
||||
from controllers.service_api.flask_admission import service_api_end_user_admission
|
||||
from controllers.service_api.schema import expect_with_user
|
||||
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from fields.conversation_fields import MessageResponseSource, 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 ServiceApiEndUserContext
|
||||
from models.enums import FeedbackRating
|
||||
from models.model import App, AppMode, EndUser
|
||||
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 (
|
||||
FirstMessageNotExistsError,
|
||||
MessageActorNotFoundError,
|
||||
MessageNotExistsError,
|
||||
SuggestedQuestionsAfterAnswerDisabledError,
|
||||
)
|
||||
@@ -130,9 +149,9 @@ class MessageListApi(Resource):
|
||||
limit=pagination.limit, has_more=pagination.has_more, data=items
|
||||
).model_dump(mode="json")
|
||||
except services.errors.conversation.ConversationNotExistsError as caught_error:
|
||||
raise NotFound("Conversation Not Exists.") from caught_error
|
||||
raise NotFoundError("Conversation Not Exists.") from caught_error
|
||||
except FirstMessageNotExistsError as caught_error:
|
||||
raise NotFound("First Message Not Exists.") from caught_error
|
||||
raise NotFoundError("First Message Not Exists.") from caught_error
|
||||
|
||||
|
||||
@service_api_ns.route("/messages/<uuid:message_id>/feedbacks")
|
||||
@@ -180,7 +199,7 @@ class MessageFeedbackApi(Resource):
|
||||
session=db.session(),
|
||||
)
|
||||
except MessageNotExistsError as caught_error:
|
||||
raise NotFound("Message Not Exists.") from caught_error
|
||||
raise NotFoundError("Message Not Exists.") from caught_error
|
||||
|
||||
return ResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
@@ -227,63 +246,103 @@ class AppGetFeedbacksApi(Resource):
|
||||
|
||||
@service_api_ns.route("/messages/<uuid:message_id>/suggested")
|
||||
class MessageSuggestedApi(Resource):
|
||||
@error_responses(NotChatAppError)
|
||||
@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 questions suggestions for the current message.",
|
||||
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={
|
||||
200: "Successfully retrieved suggested questions.",
|
||||
400: (
|
||||
HTTPStatus.OK: "Suggested questions retrieved successfully",
|
||||
HTTPStatus.BAD_REQUEST: (
|
||||
"- `not_chat_app` : App mode does not match the API route.\n"
|
||||
"- `bad_request` : Suggested questions feature is disabled."
|
||||
"- `app_unavailable` : App is no longer available.\n"
|
||||
"- `completion_request_error` : Model invocation failed while counting history tokens."
|
||||
),
|
||||
404: "`not_found` : Message does not exist.",
|
||||
500: "`internal_server_error` : Internal server error.",
|
||||
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(
|
||||
200,
|
||||
HTTPStatus.OK,
|
||||
"Suggested questions retrieved successfully",
|
||||
service_api_ns.models[SimpleResultStringListResponse.__name__],
|
||||
)
|
||||
@service_api_ns.doc(description="Get suggested follow-up questions for a message")
|
||||
@service_api_ns.doc(params={"message_id": "Message ID"})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Suggested questions retrieved successfully",
|
||||
400: "Suggested questions feature is disabled",
|
||||
401: "Unauthorized - invalid API token",
|
||||
404: "Message not found",
|
||||
500: "Internal server error",
|
||||
}
|
||||
)
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY, required=True))
|
||||
def get(self, app_model: App, end_user: EndUser, message_id: UUID):
|
||||
@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 AI-generated follow-up questions based on the message content.
|
||||
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(app_model.mode)
|
||||
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 = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app_model,
|
||||
user=end_user,
|
||||
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,
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=db.session(),
|
||||
)
|
||||
except AppDefinitionUnavailableError:
|
||||
raise AppUnavailableError() from None
|
||||
except MessageActorNotFoundError:
|
||||
raise NotFoundError("End user not found") from None
|
||||
except MessageNotExistsError as caught_error:
|
||||
raise NotFound("Message Not Exists.") from caught_error
|
||||
except SuggestedQuestionsAfterAnswerDisabledError as caught_error:
|
||||
raise BadRequest("Suggested Questions Is Disabled.") from 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 SimpleResultStringListResponse(result="success", data=questions).model_dump(mode="json")
|
||||
return dump_response(SimpleResultStringListResponse, {"result": "success", "data": questions})
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Service API admission for handlers that consume stable app and end-user values."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Concatenate
|
||||
|
||||
from flask import request
|
||||
|
||||
from controllers.common.resource_access_token_errors import resource_access_token_errors
|
||||
from controllers.service_api.app import error as http_errors
|
||||
from controllers.service_api.wraps import (
|
||||
FetchUserArg,
|
||||
document_app_token_contract,
|
||||
peek_service_api_bearer_token,
|
||||
resolve_service_api_end_user,
|
||||
validate_and_get_api_token,
|
||||
)
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.external_api import document_errors
|
||||
from machinery.context import ServiceApiEndUserContext
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.auth.resource_access_token_contracts import is_resource_access_token
|
||||
from services.errors.app import AppAbnormalStatusError, AppApiDisabledError
|
||||
from services.errors.workspace import WorkspaceArchivedError, WorkspaceNotFoundError
|
||||
|
||||
|
||||
def service_api_end_user_admission[T, **P, R](
|
||||
*,
|
||||
fetch_user_arg: FetchUserArg,
|
||||
) -> Callable[
|
||||
[Callable[Concatenate[T, ServiceApiEndUserContext, P], R]],
|
||||
Callable[Concatenate[T, P], R],
|
||||
]:
|
||||
"""Validate API access before provisioning an end user and injecting scalar identity.
|
||||
|
||||
App/workspace reads finish before user provisioning or the handler runs. The
|
||||
shared login helper retains Flask's request identity for legacy dependencies;
|
||||
neither an ORM object nor its session is passed to the handler.
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
view: Callable[Concatenate[T, ServiceApiEndUserContext, P], R],
|
||||
) -> Callable[Concatenate[T, P], R]:
|
||||
@wraps(view)
|
||||
def admitted(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
auth_token = peek_service_api_bearer_token()
|
||||
tenant_id = None
|
||||
if auth_token and is_resource_access_token(auth_token):
|
||||
with resource_access_token_errors():
|
||||
grant = application_services().resource_access_tokens.resolve_app_for_service_api(
|
||||
token=auth_token,
|
||||
requested_app_id=request.headers.get("X-Dify-App-ID"),
|
||||
)
|
||||
app_id = next(iter(grant.app_ids))
|
||||
tenant_id = grant.tenant_id
|
||||
else:
|
||||
token = validate_and_get_api_token("app")
|
||||
app_id = token.app_id
|
||||
try:
|
||||
app = application_services().app_definitions.get_service_api_app(app_id, tenant_id=tenant_id)
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise http_errors.AppNotFoundError() from error
|
||||
except AppAbnormalStatusError as error:
|
||||
raise http_errors.AppAbnormalStatusError() from error
|
||||
except AppApiDisabledError as error:
|
||||
raise http_errors.AppApiDisabledError() from error
|
||||
except WorkspaceNotFoundError as error:
|
||||
raise http_errors.WorkspaceNotFoundError() from error
|
||||
except WorkspaceArchivedError as error:
|
||||
raise http_errors.WorkspaceArchivedError() from error
|
||||
|
||||
end_user = resolve_service_api_end_user(
|
||||
tenant_id=app.tenant_id, app_id=app.app_id, fetch_user_arg=fetch_user_arg
|
||||
)
|
||||
context = ServiceApiEndUserContext(
|
||||
tenant_id=app.tenant_id,
|
||||
app_id=app.app_id,
|
||||
app_mode=app.mode,
|
||||
end_user_id=end_user.id,
|
||||
)
|
||||
return view(self, context, *args, **kwargs)
|
||||
|
||||
document_app_token_contract(admitted, fetch_user_arg)
|
||||
document_errors(
|
||||
admitted,
|
||||
http_errors.AppNotFoundError,
|
||||
http_errors.AppAbnormalStatusError,
|
||||
http_errors.AppApiDisabledError,
|
||||
http_errors.WorkspaceNotFoundError,
|
||||
http_errors.WorkspaceArchivedError,
|
||||
)
|
||||
return admitted
|
||||
|
||||
return decorator
|
||||
@@ -29,7 +29,7 @@ from extensions.ext_database import db
|
||||
from libs.login import current_user
|
||||
from models import Account, Tenant, TenantAccountJoin, TenantStatus
|
||||
from models.dataset import Dataset
|
||||
from models.model import ApiToken
|
||||
from models.model import ApiToken, EndUser
|
||||
from repositories.knowledge import dataset_api_key_bindings
|
||||
from services.api_token_service import ApiTokenCache, fetch_token_with_single_flight, record_token_usage
|
||||
from services.app_service import AppService
|
||||
@@ -77,7 +77,7 @@ VECTOR_SPACE_UNAVAILABLE_RESPONSE = {
|
||||
}
|
||||
|
||||
|
||||
def _document_app_token_contract(view_func: Callable[..., object], fetch_user_arg: FetchUserArg | None) -> None:
|
||||
def document_app_token_contract(view_func: Callable[..., object], fetch_user_arg: FetchUserArg | None) -> None:
|
||||
doc: dict[str, object] = {"responses": APP_TOKEN_FORBIDDEN_RESPONSE}
|
||||
if fetch_user_arg is not None:
|
||||
setattr(view_func, USER_FETCH_FROM_ATTR, fetch_user_arg.fetch_from.name)
|
||||
@@ -112,6 +112,10 @@ def validate_app_token[**P, R](
|
||||
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view_func)
|
||||
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
# TODO: Remove these duplicate app/workspace checks when all legacy
|
||||
# ORM App/EndUser handlers (including account-only endpoints) migrate
|
||||
# to scalar admission. Keep resource-token routing and app policy aligned
|
||||
# with service_api_end_user_admission until then.
|
||||
auth_token = peek_service_api_bearer_token()
|
||||
if auth_token and is_resource_access_token(auth_token):
|
||||
with resource_access_token_errors():
|
||||
@@ -146,31 +150,9 @@ def validate_app_token[**P, R](
|
||||
|
||||
# If caller needs end-user context, attach EndUser to current_user
|
||||
if fetch_user_arg:
|
||||
user_id = None
|
||||
match fetch_user_arg.fetch_from:
|
||||
case WhereisUserArg.QUERY:
|
||||
user_id = request.args.get("user")
|
||||
case WhereisUserArg.JSON:
|
||||
user_id = request.get_json().get("user")
|
||||
case WhereisUserArg.FORM:
|
||||
user_id = request.form.get("user")
|
||||
|
||||
if not user_id and fetch_user_arg.required:
|
||||
raise ValueError("Arg user must be provided.")
|
||||
|
||||
if user_id:
|
||||
user_id = str(user_id)
|
||||
|
||||
end_user = application_services().app_scoped_end_users.commands.get_or_create_end_user(
|
||||
app_model.tenant_id,
|
||||
app_model.id,
|
||||
user_id,
|
||||
kwargs["end_user"] = resolve_service_api_end_user(
|
||||
tenant_id=app_model.tenant_id, app_id=app_model.id, fetch_user_arg=fetch_user_arg
|
||||
)
|
||||
kwargs["end_user"] = end_user
|
||||
|
||||
# Set EndUser as current logged-in user for flask_login.current_user
|
||||
current_app.login_manager._update_request_context_with_user(end_user) # type: ignore
|
||||
user_logged_in.send(current_app._get_current_object(), user=end_user) # type: ignore
|
||||
else:
|
||||
# For service API without end-user context, ensure an Account is logged in
|
||||
# so services relying on current_account_with_tenant() work correctly.
|
||||
@@ -195,7 +177,7 @@ def validate_app_token[**P, R](
|
||||
|
||||
return view_func(*args, **kwargs)
|
||||
|
||||
_document_app_token_contract(decorated_view, fetch_user_arg)
|
||||
document_app_token_contract(decorated_view, fetch_user_arg)
|
||||
return decorated_view
|
||||
|
||||
if view is None:
|
||||
@@ -204,6 +186,28 @@ def validate_app_token[**P, R](
|
||||
return decorator(view)
|
||||
|
||||
|
||||
def resolve_service_api_end_user(*, tenant_id: str, app_id: str, fetch_user_arg: FetchUserArg) -> EndUser:
|
||||
"""Share user parsing, provisioning and Flask login identity across API admission paths."""
|
||||
user_id = None
|
||||
match fetch_user_arg.fetch_from:
|
||||
case WhereisUserArg.QUERY:
|
||||
user_id = request.args.get("user")
|
||||
case WhereisUserArg.JSON:
|
||||
user_id = request.get_json().get("user")
|
||||
case WhereisUserArg.FORM:
|
||||
user_id = request.form.get("user")
|
||||
|
||||
if not user_id and fetch_user_arg.required:
|
||||
raise ValueError("Arg user must be provided.")
|
||||
if user_id:
|
||||
user_id = str(user_id)
|
||||
|
||||
end_user = application_services().app_scoped_end_users.commands.get_or_create_end_user(tenant_id, app_id, user_id)
|
||||
current_app.login_manager._update_request_context_with_user(end_user) # type: ignore
|
||||
user_logged_in.send(current_app._get_current_object(), user=end_user) # type: ignore
|
||||
return end_user
|
||||
|
||||
|
||||
def cloud_edition_billing_resource_check[**P, R](
|
||||
resource: str,
|
||||
api_token_type: str,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import logging
|
||||
from http import HTTPStatus
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import InternalServerError, NotFound
|
||||
|
||||
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
|
||||
from controllers.common.errors import InternalServerError, NotFoundError
|
||||
from controllers.common.fields import GeneratedAppResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console.app.wraps import with_session
|
||||
@@ -15,6 +16,7 @@ from controllers.web import web_ns
|
||||
from controllers.web.error import (
|
||||
AppMoreLikeThisDisabledError,
|
||||
AppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
NotChatAppError,
|
||||
NotCompletionAppError,
|
||||
@@ -25,6 +27,7 @@ from controllers.web.error import (
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.conversation_fields import MessageResponseSource, ResultResponse
|
||||
from fields.message_fields import SuggestedQuestionsResponse, WebMessageInfiniteScrollPagination, WebMessageListItem
|
||||
@@ -33,11 +36,15 @@ from libs import helper
|
||||
from libs.external_api import error_responses
|
||||
from models.enums import FeedbackRating
|
||||
from models.model import App, AppMode, EndUser
|
||||
from services.agent.errors import AgentVersionNotFoundError
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.entities.message_entities import MessageEndUser
|
||||
from services.errors.app import MoreLikeThisDisabledError
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import (
|
||||
FirstMessageNotExistsError,
|
||||
MessageActorNotFoundError,
|
||||
MessageNotExistsError,
|
||||
SuggestedQuestionsAfterAnswerDisabledError,
|
||||
)
|
||||
@@ -100,9 +107,9 @@ class MessageListApi(WebApiResource):
|
||||
data=items,
|
||||
).model_dump(mode="json")
|
||||
except ConversationNotExistsError as caught_error:
|
||||
raise NotFound("Conversation Not Exists.") from caught_error
|
||||
raise NotFoundError("Conversation Not Exists.") from caught_error
|
||||
except FirstMessageNotExistsError as caught_error:
|
||||
raise NotFound("First Message Not Exists.") from caught_error
|
||||
raise NotFoundError("First Message Not Exists.") from caught_error
|
||||
|
||||
|
||||
@web_ns.route("/messages/<uuid:message_id>/feedbacks")
|
||||
@@ -146,7 +153,7 @@ class MessageFeedbackApi(WebApiResource):
|
||||
session=db.session(),
|
||||
)
|
||||
except MessageNotExistsError as caught_error:
|
||||
raise NotFound("Message Not Exists.") from caught_error
|
||||
raise NotFoundError("Message Not Exists.") from caught_error
|
||||
|
||||
return ResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
@@ -205,7 +212,7 @@ class MessageMoreLikeThisApi(WebApiResource):
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except MessageNotExistsError as caught_error:
|
||||
raise NotFound("Message Not Exists.") from caught_error
|
||||
raise NotFoundError("Message Not Exists.") from caught_error
|
||||
except MoreLikeThisDisabledError as caught_error:
|
||||
raise AppMoreLikeThisDisabledError() from caught_error
|
||||
except ProviderTokenNotInitError as ex:
|
||||
@@ -226,28 +233,41 @@ class MessageMoreLikeThisApi(WebApiResource):
|
||||
@web_ns.route("/messages/<uuid:message_id>/suggested-questions")
|
||||
class MessageSuggestedQuestionApi(WebApiResource):
|
||||
@error_responses(
|
||||
AgentVersionNotFoundError,
|
||||
AppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
NotChatAppError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
)
|
||||
@web_ns.response(200, "Success", web_ns.models[SuggestedQuestionsResponse.__name__])
|
||||
@web_ns.response(HTTPStatus.OK, "Success", web_ns.models[SuggestedQuestionsResponse.__name__])
|
||||
@web_ns.doc("Get Suggested Questions")
|
||||
@web_ns.doc(description="Get suggested follow-up questions after a message (chat apps only).")
|
||||
@web_ns.doc(
|
||||
description=(
|
||||
"Get suggested follow-up questions after a message (chat apps only). "
|
||||
"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`."
|
||||
)
|
||||
)
|
||||
@web_ns.doc(params={"message_id": {"description": "Message UUID", "type": "string", "required": True}})
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request - Not a chat app or feature disabled",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Message Not Found or Conversation Not Found",
|
||||
500: "Internal Server Error",
|
||||
HTTPStatus.OK: "Success",
|
||||
HTTPStatus.BAD_REQUEST: (
|
||||
"Bad Request - Not a chat app or app unavailable; "
|
||||
"`completion_request_error` when model invocation fails while counting history tokens."
|
||||
),
|
||||
HTTPStatus.UNAUTHORIZED: "Unauthorized",
|
||||
HTTPStatus.FORBIDDEN: "Forbidden - Access denied or suggested questions disabled",
|
||||
HTTPStatus.NOT_FOUND: "App, End User, Message, or Conversation Not Found",
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def get(self, app_model: App, end_user: EndUser, message_id: UUID):
|
||||
def get(self, app_model: App, end_user: EndUser, message_id: UUID) -> dict[str, object]:
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
@@ -255,18 +275,22 @@ class MessageSuggestedQuestionApi(WebApiResource):
|
||||
message_id_str = str(message_id)
|
||||
|
||||
try:
|
||||
questions = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app_model,
|
||||
user=end_user,
|
||||
questions = application_services().message_suggested_questions.get_suggested_questions(
|
||||
app_id=app_model.id,
|
||||
app_owner_tenant_id=app_model.tenant_id,
|
||||
expected_app_mode=app_model.mode,
|
||||
actor=MessageEndUser(end_user_id=end_user.id),
|
||||
invoke_from="web-app",
|
||||
message_id=message_id_str,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
session=db.session(),
|
||||
)
|
||||
# questions is a list of strings, not a list of Message objects
|
||||
except AppDefinitionUnavailableError:
|
||||
raise AppUnavailableError() from None
|
||||
except MessageActorNotFoundError:
|
||||
raise NotFoundError("End user not found") from None
|
||||
except MessageNotExistsError as caught_error:
|
||||
raise NotFound("Message not found") from caught_error
|
||||
raise NotFoundError("Message not found") from caught_error
|
||||
except ConversationNotExistsError as caught_error:
|
||||
raise NotFound("Conversation not found") from caught_error
|
||||
raise NotFoundError("Conversation not found") from caught_error
|
||||
except SuggestedQuestionsAfterAnswerDisabledError as caught_error:
|
||||
raise AppSuggestedQuestionsAfterAnswerDisabledError() from caught_error
|
||||
except ProviderTokenNotInitError as ex:
|
||||
@@ -277,8 +301,12 @@ class MessageSuggestedQuestionApi(WebApiResource):
|
||||
raise ProviderModelCurrentlyNotSupportError() from caught_error
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description) from e
|
||||
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 SuggestedQuestionsResponse(data=questions).model_dump(mode="json")
|
||||
return helper.dump_response(SuggestedQuestionsResponse, {"data": questions})
|
||||
@@ -2,8 +2,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, NotRequired, Protocol, TypedDict, cast
|
||||
from typing import Any, Literal, Protocol, TypedDict, cast
|
||||
|
||||
import json_repair
|
||||
from sqlalchemy import select
|
||||
@@ -13,7 +12,6 @@ from core.app.app_config.entities import ModelConfig
|
||||
from core.app.entities.app_invoke_entities import CreditUsageCreatedBy
|
||||
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
|
||||
from core.llm_generator.output_parser.rule_config_generator import RuleConfigGeneratorOutputParser
|
||||
from core.llm_generator.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser
|
||||
from core.llm_generator.prompts import (
|
||||
CONVERSATION_TITLE_PROMPT,
|
||||
GENERATOR_QA_PROMPT,
|
||||
@@ -25,11 +23,10 @@ from core.llm_generator.prompts import (
|
||||
WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE,
|
||||
)
|
||||
from core.model_context import with_credit_usage_created_by
|
||||
from core.model_manager import ModelInstance, ModelManager
|
||||
from core.model_manager import ModelManager
|
||||
from core.ops.entities.trace_entity import TraceTaskName
|
||||
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
|
||||
from core.ops.utils import measure_time
|
||||
from core.plugin.impl.base import use_plugin_daemon_request_timeout
|
||||
from core.prompt.utils.prompt_template_parser import PromptTemplateParser
|
||||
from core.telemetry import PromptGenerationEvent, TelemetryContext
|
||||
from core.telemetry import emit as telemetry_emit
|
||||
@@ -38,31 +35,13 @@ from extensions.ext_storage import storage
|
||||
from graphon.enums import WorkflowNodeExecutionMetadataKey
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResult
|
||||
from graphon.model_runtime.entities.message_entities import PromptMessage, SystemPromptMessage, UserPromptMessage
|
||||
from graphon.model_runtime.entities.model_entities import ModelType, ParameterType
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from models import App, Message, WorkflowNodeExecutionModel
|
||||
from models.workflow import Workflow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SUGGESTED_QUESTIONS_MAX_TOKENS = 256
|
||||
_SUGGESTED_QUESTIONS_TIMEOUT_SECONDS = 30.0
|
||||
_LOW_REASONING_EFFORTS = ("none", "minimal", "low")
|
||||
|
||||
|
||||
class SuggestedQuestionsModelConfig(TypedDict):
|
||||
provider: str
|
||||
name: str
|
||||
completion_params: NotRequired[dict[str, object]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedSuggestedQuestionsModel:
|
||||
"""Resolved model credentials; ``None`` parameters select the default-model tuning."""
|
||||
|
||||
model_instance: ModelInstance
|
||||
completion_params: dict[str, object] | None
|
||||
|
||||
|
||||
def _normalize_completion_params(completion_params: dict[str, object]) -> tuple[dict[str, object], list[str]]:
|
||||
"""
|
||||
@@ -85,39 +64,6 @@ def _normalize_completion_params(completion_params: dict[str, object]) -> tuple[
|
||||
return normalized_parameters, stop
|
||||
|
||||
|
||||
def _default_suggested_questions_model_parameters(model_instance: ModelInstance) -> dict[str, object]:
|
||||
"""Build a low-latency parameter set for the workspace default model."""
|
||||
parameters: dict[str, object] = {
|
||||
"max_tokens": _SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
|
||||
try:
|
||||
model_schema = model_instance.get_model_schema()
|
||||
except Exception:
|
||||
logger.warning("Failed to inspect the default model schema for suggested questions", exc_info=True)
|
||||
return parameters
|
||||
|
||||
parameter_rules = {rule.name: rule for rule in model_schema.parameter_rules}
|
||||
thinking_rule = parameter_rules.get("thinking")
|
||||
if thinking_rule is not None:
|
||||
if thinking_rule.type == ParameterType.BOOLEAN:
|
||||
parameters["thinking"] = False
|
||||
return parameters
|
||||
if "disabled" in thinking_rule.options:
|
||||
parameters["thinking"] = "disabled"
|
||||
return parameters
|
||||
|
||||
reasoning_effort_rule = parameter_rules.get("reasoning_effort")
|
||||
if reasoning_effort_rule is not None:
|
||||
for effort in _LOW_REASONING_EFFORTS:
|
||||
if effort in reasoning_effort_rule.options:
|
||||
parameters["reasoning_effort"] = effort
|
||||
break
|
||||
|
||||
return parameters
|
||||
|
||||
|
||||
# ── Workflow instruction-suggestion tuning ────────────────────────────────
|
||||
# Suggestions are a soft, pre-model-pick enhancement: short, buildable example
|
||||
# instructions proposed from the tenant's DEFAULT model. Every failure path
|
||||
@@ -296,129 +242,6 @@ class LLMGenerator:
|
||||
|
||||
return name
|
||||
|
||||
@classmethod
|
||||
@with_credit_usage_created_by(CreditUsageCreatedBy.SUGGESTED_QUESTIONS)
|
||||
def generate_suggested_questions_after_answer(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
histories: str,
|
||||
*,
|
||||
instruction_prompt: str | None = None,
|
||||
model_config: object | None = None,
|
||||
) -> Sequence[str]:
|
||||
prepared_model = cls.prepare_suggested_questions_model(tenant_id, model_config=model_config)
|
||||
if prepared_model is None:
|
||||
return []
|
||||
return cls.invoke_suggested_questions_after_answer(
|
||||
prepared_model, histories, instruction_prompt=instruction_prompt
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@with_credit_usage_created_by(CreditUsageCreatedBy.SUGGESTED_QUESTIONS)
|
||||
def prepare_suggested_questions_model(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
*,
|
||||
model_config: object | None = None,
|
||||
) -> PreparedSuggestedQuestionsModel | None:
|
||||
"""Resolve the model before its caller releases database sessions.
|
||||
|
||||
``None`` means no usable configured or default model was found. Schema
|
||||
inspection and generation belong to invocation, after the read phase.
|
||||
"""
|
||||
try:
|
||||
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
|
||||
configured_model = cast(dict[str, object], model_config) if isinstance(model_config, dict) else {}
|
||||
provider = configured_model.get("provider")
|
||||
model_name = configured_model.get("name")
|
||||
use_configured_model = False
|
||||
|
||||
if isinstance(provider, str) and provider and isinstance(model_name, str) and model_name:
|
||||
try:
|
||||
model_instance = model_manager.get_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
provider=provider,
|
||||
model=model_name,
|
||||
)
|
||||
use_configured_model = True
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to use configured suggested-questions model %s/%s, fallback to default model",
|
||||
provider,
|
||||
model_name,
|
||||
exc_info=True,
|
||||
)
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
else:
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to resolve the suggested-questions model")
|
||||
return None
|
||||
|
||||
completion_params: dict[str, object] | None = None
|
||||
if use_configured_model:
|
||||
configured_completion_params = configured_model.get("completion_params")
|
||||
completion_params = (
|
||||
dict(configured_completion_params) if isinstance(configured_completion_params, dict) else {}
|
||||
)
|
||||
return PreparedSuggestedQuestionsModel(
|
||||
model_instance=model_instance,
|
||||
completion_params=completion_params,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@with_credit_usage_created_by(CreditUsageCreatedBy.SUGGESTED_QUESTIONS)
|
||||
def invoke_suggested_questions_after_answer(
|
||||
cls,
|
||||
prepared_model: PreparedSuggestedQuestionsModel,
|
||||
histories: str,
|
||||
*,
|
||||
instruction_prompt: str | None = None,
|
||||
) -> Sequence[str]:
|
||||
"""Generate with an already resolved model without repeating model resolution."""
|
||||
output_parser = SuggestedQuestionsAfterAnswerOutputParser(instruction_prompt=instruction_prompt)
|
||||
format_instructions = output_parser.get_format_instructions()
|
||||
prompt_template = PromptTemplateParser(template="{{histories}}\n{{format_instructions}}\nquestions:\n")
|
||||
prompt = prompt_template.format({"histories": histories, "format_instructions": format_instructions})
|
||||
|
||||
prompt_messages: list[PromptMessage] = [UserPromptMessage(content=prompt)]
|
||||
|
||||
questions: Sequence[str] = []
|
||||
|
||||
try:
|
||||
model_parameters: dict[str, object]
|
||||
stop: list[str]
|
||||
model_instance = prepared_model.model_instance
|
||||
if prepared_model.completion_params is not None:
|
||||
model_parameters, stop = _normalize_completion_params(prepared_model.completion_params)
|
||||
else:
|
||||
# Default-model generation keeps the built-in suggested-questions tuning.
|
||||
model_parameters = _default_suggested_questions_model_parameters(model_instance)
|
||||
stop = []
|
||||
|
||||
with use_plugin_daemon_request_timeout(_SUGGESTED_QUESTIONS_TIMEOUT_SECONDS):
|
||||
response: LLMResult = model_instance.invoke_llm(
|
||||
prompt_messages=list(prompt_messages),
|
||||
model_parameters=model_parameters,
|
||||
stop=stop,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
text_content = response.message.get_text_content()
|
||||
questions = output_parser.parse(text_content) if text_content else []
|
||||
except Exception:
|
||||
logger.exception("Failed to generate suggested questions after answer")
|
||||
questions = []
|
||||
|
||||
return questions
|
||||
|
||||
@classmethod
|
||||
@with_credit_usage_created_by(CreditUsageCreatedBy.WORKFLOW_INSTRUCTION_SUGGESTIONS)
|
||||
def generate_workflow_instruction_suggestions(
|
||||
|
||||
@@ -171,7 +171,7 @@ class TokenBufferMemory:
|
||||
workflow = session.scalar(select(Workflow).where(Workflow.id == workflow_run.workflow_id))
|
||||
if not workflow:
|
||||
raise ValueError(f"Workflow not found: {workflow_run.workflow_id}")
|
||||
return FileUploadConfigManager.convert(workflow.features_dict, is_vision=False)
|
||||
return FileUploadConfigManager.convert(workflow.normalized_features_dict, is_vision=False)
|
||||
case _:
|
||||
raise AssertionError(f"Invalid app mode: {conversation.mode}")
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ from repositories.file_grant_repository import FileGrantRepository
|
||||
from repositories.human_input_file_upload_repository import SQLAlchemyHumanInputFileUploadRepository
|
||||
from repositories.installation_state_repository import InstallationStateRepository
|
||||
from repositories.message_file_preview_repository import MessageFilePreviewQueryRepository
|
||||
from repositories.message_repository import MessageRepository
|
||||
from repositories.network_access_group_repository import SQLAlchemyNetworkAccessGroupAppRepository
|
||||
from repositories.oauth_device_token_repository import SQLAlchemyOAuthDeviceTokenRepository
|
||||
from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository
|
||||
@@ -144,8 +145,9 @@ from services.knowledge.api_key_service import DatasetApiKeyService
|
||||
from services.knowledge_fs.builtin_tool.knowledge_fs.knowledge_fs import KnowledgeFSProvider
|
||||
from services.knowledge_fs.workflow_runtime import build_knowledge_fs_node_dependencies
|
||||
from services.message_file_preview_service import MessageFilePreviewService
|
||||
from services.message_suggested_questions_adapters import MessageSuggestedQuestionsRuntime
|
||||
from services.message_suggested_questions_service import MessageSuggestedQuestions
|
||||
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
|
||||
from services.message_suggested_questions_queries import SuggestedQuestionsQuery
|
||||
from services.message_suggested_questions_service import MessageSuggestedQuestions, MessageSuggestedQuestionsService
|
||||
from services.network_access_group_gateway import (
|
||||
BillingNetworkAccessGroupEntitlementGateway,
|
||||
NetworkAccessGroupGateway,
|
||||
@@ -535,6 +537,11 @@ def build_application_services(
|
||||
registration=account_services.lifecycle,
|
||||
invitation_tokens=invitation_tokens,
|
||||
)
|
||||
suggested_questions = SuggestedQuestionsQuery(
|
||||
session_factory=database_client,
|
||||
repository_factory=MessageRepository,
|
||||
)
|
||||
|
||||
return ApplicationServices(
|
||||
rbac=rbac,
|
||||
accounts=account_services,
|
||||
@@ -656,7 +663,10 @@ def build_application_services(
|
||||
files=MessageFilePreviewQueryRepository(session_factory=database_client),
|
||||
storage=storage,
|
||||
),
|
||||
message_suggested_questions=MessageSuggestedQuestionsRuntime(session_factory=database_client),
|
||||
message_suggested_questions=MessageSuggestedQuestionsService(
|
||||
queries=suggested_questions,
|
||||
generator=SuggestedQuestionsGenerator(),
|
||||
),
|
||||
plugin_file_uploads=PluginFileUploadService(
|
||||
owners=SQLAlchemyPluginFileUploadOwnerRepository(session_factory=database_client),
|
||||
files=ToolFilePluginUploadGateway(tool_files=ToolFileManager()),
|
||||
|
||||
@@ -27,6 +27,14 @@ class RequestContext(NamedTuple):
|
||||
resource_app_ids: frozenset[str] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ServiceApiEndUserContext(AppRequestContext):
|
||||
"""Validated application mode and end-user identity for a Service API request."""
|
||||
|
||||
app_mode: str
|
||||
end_user_id: str
|
||||
|
||||
|
||||
class AccountRequestContext(NamedTuple):
|
||||
"""Stable identity for account-scoped use cases that do not require a workspace."""
|
||||
|
||||
|
||||
@@ -443,7 +443,13 @@ class Workflow(Base): # bug
|
||||
|
||||
@staticmethod
|
||||
def _normalize_features_payload(features: dict[str, Any]) -> dict[str, Any]:
|
||||
if features.get("file_upload", {}).get("image", {}).get("enabled", False):
|
||||
file_upload = features.get("file_upload")
|
||||
if not isinstance(file_upload, dict):
|
||||
return features
|
||||
image = file_upload.get("image")
|
||||
if not isinstance(image, dict):
|
||||
return features
|
||||
if image.get("enabled", False):
|
||||
image_number_limits = int(features["file_upload"]["image"].get("number_limits", DEFAULT_FILE_NUMBER_LIMITS))
|
||||
image_transfer_methods = features["file_upload"]["image"].get(
|
||||
"transfer_methods", ["remote_url", "local_file"]
|
||||
|
||||
Generated
+2
-1
@@ -2979,7 +2979,7 @@ Get suggested questions for a message
|
||||
| 400 | App or model provider unavailable, or generation failed | **application/json**: [AppUnavailableError](#appunavailableerror)<br>[CompletionRequestError](#completionrequesterror)<br>[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)<br>[ProviderNotInitializeError](#providernotinitializeerror)<br>[ProviderQuotaExceededError](#providerquotaexceedederror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 401 | Account authentication required | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | Insufficient permissions or suggested questions disabled | **application/json**: [AppSuggestedQuestionsAfterAnswerDisabledError](#appsuggestedquestionsafteranswerdisablederror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | App, message, or conversation not found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | App, message, or conversation not found | **application/json**: [AgentVersionNotFoundError](#agentversionnotfounderror)<br>[AppNotFoundError](#appnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 500 | Unexpected server error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
@@ -13774,6 +13774,7 @@ Remove one or more tag bindings from a target.
|
||||
| 200 | Success | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)<br> |
|
||||
| 400 | Bad Request | **application/json**: [AppUnavailableError](#appunavailableerror)<br>[CompletionRequestError](#completionrequesterror)<br>[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)<br>[NotChatAppError](#notchatapperror)<br>[ProviderNotInitializeError](#providernotinitializeerror)<br>[ProviderQuotaExceededError](#providerquotaexceedederror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | Forbidden | **application/json**: [AppSuggestedQuestionsAfterAnswerDisabledError](#appsuggestedquestionsafteranswerdisablederror)<br>[TrialAppFeatureDisabledError](#trialappfeaturedisablederror)<br>[TrialAppLimitExceededError](#trialapplimitexceedederror)<br>[TrialAppNotAllowedError](#trialappnotallowederror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | Not Found | **application/json**: [AgentVersionNotFoundError](#agentversionnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
### [GET] /trial-apps/{app_id}/parameters
|
||||
|
||||
Generated
+64
-8
@@ -2024,7 +2024,7 @@ Stops a chat message generation task. Only supported in `streaming` mode.
|
||||
### [GET] /messages/{message_id}/suggested
|
||||
**Get Next Suggested Questions**
|
||||
|
||||
Get next questions suggestions for the current message.
|
||||
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`.
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -2038,10 +2038,10 @@ Get next questions suggestions for the current message.
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Suggested questions retrieved successfully | **application/json**: [SimpleResultStringListResponse](#simpleresultstringlistresponse)<br> |
|
||||
| 400 | - `not_chat_app` : App mode does not match the API route. - `bad_request` : Suggested questions feature is disabled. | **application/json**: [NotChatAppError](#notchatapperror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 400 | - `not_chat_app` : App mode does not match the API route. - `app_unavailable` : App is no longer available. - `completion_request_error` : Model invocation failed while counting history tokens. | **application/json**: [AppUnavailableError](#appunavailableerror)<br>[CompletionRequestError](#completionrequesterror)<br>[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)<br>[NotChatAppError](#notchatapperror)<br>[ProviderNotInitializeError](#providernotinitializeerror)<br>[ProviderQuotaExceededError](#providerquotaexceedederror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 401 | Unauthorized - invalid API token | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | Forbidden - token scope, app, dataset, or workspace access denied | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | `not_found` : Message does not exist. | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | - `forbidden` : Token scope does not allow access. - `app_not_found` : The token's app no longer exists. - `app_abnormal_status` : App status does not allow API access. - `app_api_disabled` : The app's API service has been disabled. - `workspace_not_found` : The app's workspace no longer exists. - `workspace_archived` : The app's workspace is archived. - `app_suggested_questions_after_answer_disabled` : Suggested questions feature is disabled. | **application/json**: [AppAbnormalStatusError](#appabnormalstatuserror)<br>[AppApiDisabledError](#appapidisablederror)<br>[AppNotFoundError](#appnotfounderror)<br>[AppSuggestedQuestionsAfterAnswerDisabledError](#appsuggestedquestionsafteranswerdisablederror)<br>[WorkspaceArchivedError](#workspacearchivederror)<br>[WorkspaceNotFoundError](#workspacenotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | - `not_found` : End user, message, or conversation does not exist. - `agent_version_not_found_error` : Agent config version does not exist. | **application/json**: [AgentVersionNotFoundError](#agentversionnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 500 | `internal_server_error` : Internal server error. | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
@@ -2177,7 +2177,7 @@ Stops a chat message generation task. Only supported in `streaming` mode.
|
||||
### [GET] /messages/{message_id}/suggested
|
||||
**Get Next Suggested Questions**
|
||||
|
||||
Get next questions suggestions for the current message.
|
||||
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`.
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -2191,10 +2191,10 @@ Get next questions suggestions for the current message.
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Suggested questions retrieved successfully | **application/json**: [SimpleResultStringListResponse](#simpleresultstringlistresponse)<br> |
|
||||
| 400 | - `not_chat_app` : App mode does not match the API route. - `bad_request` : Suggested questions feature is disabled. | **application/json**: [NotChatAppError](#notchatapperror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 400 | - `not_chat_app` : App mode does not match the API route. - `app_unavailable` : App is no longer available. - `completion_request_error` : Model invocation failed while counting history tokens. | **application/json**: [AppUnavailableError](#appunavailableerror)<br>[CompletionRequestError](#completionrequesterror)<br>[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)<br>[NotChatAppError](#notchatapperror)<br>[ProviderNotInitializeError](#providernotinitializeerror)<br>[ProviderQuotaExceededError](#providerquotaexceedederror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 401 | Unauthorized - invalid API token | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | Forbidden - token scope, app, dataset, or workspace access denied | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | `not_found` : Message does not exist. | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | - `forbidden` : Token scope does not allow access. - `app_not_found` : The token's app no longer exists. - `app_abnormal_status` : App status does not allow API access. - `app_api_disabled` : The app's API service has been disabled. - `workspace_not_found` : The app's workspace no longer exists. - `workspace_archived` : The app's workspace is archived. - `app_suggested_questions_after_answer_disabled` : Suggested questions feature is disabled. | **application/json**: [AppAbnormalStatusError](#appabnormalstatuserror)<br>[AppApiDisabledError](#appapidisablederror)<br>[AppNotFoundError](#appnotfounderror)<br>[AppSuggestedQuestionsAfterAnswerDisabledError](#appsuggestedquestionsafteranswerdisablederror)<br>[WorkspaceArchivedError](#workspacearchivederror)<br>[WorkspaceNotFoundError](#workspacenotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | - `not_found` : End user, message, or conversation does not exist. - `agent_version_not_found_error` : Agent config version does not exist. | **application/json**: [AgentVersionNotFoundError](#agentversionnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 500 | `internal_server_error` : Internal server error. | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
@@ -4068,6 +4068,14 @@ Agent has not been published. Please publish the Agent before using the API.
|
||||
| tool_input | string | | No |
|
||||
| tool_labels | [AnyJSONValue](#anyjsonvalue) | Labels for tools used. | Yes |
|
||||
|
||||
#### AgentVersionNotFoundError
|
||||
|
||||
Agent config version not found.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentVersionNotFoundError | | Agent config version not found. | |
|
||||
|
||||
#### Annotation
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -4132,6 +4140,22 @@ Agent has not been published. Please publish the Agent before using the API.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AnyJSONValue | | | |
|
||||
|
||||
#### AppAbnormalStatusError
|
||||
|
||||
The app's status is abnormal.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AppAbnormalStatusError | | The app's status is abnormal. | |
|
||||
|
||||
#### AppApiDisabledError
|
||||
|
||||
The app's API service has been disabled.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AppApiDisabledError | | The app's API service has been disabled. | |
|
||||
|
||||
#### AppFeedbackListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -4170,6 +4194,22 @@ Agent has not been published. Please publish the Agent before using the API.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| tool_icons | object | | No |
|
||||
|
||||
#### AppNotFoundError
|
||||
|
||||
The app no longer exists.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AppNotFoundError | | The app no longer exists. | |
|
||||
|
||||
#### AppSuggestedQuestionsAfterAnswerDisabledError
|
||||
|
||||
The 'Suggested Questions After Answer' feature is disabled.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AppSuggestedQuestionsAfterAnswerDisabledError | | The 'Suggested Questions After Answer' feature is disabled. | |
|
||||
|
||||
#### AppUnavailableError
|
||||
|
||||
App unavailable, please check your app configurations.
|
||||
@@ -8036,3 +8076,19 @@ Workflow version execution is not available on your current plan. Please upgrade
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| WorkflowVersionExecutionNotAllowedError | | Workflow version execution is not available on your current plan. Please upgrade to a paid plan. | |
|
||||
|
||||
#### WorkspaceArchivedError
|
||||
|
||||
The workspace's status is archived.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| WorkspaceArchivedError | | The workspace's status is archived. | |
|
||||
|
||||
#### WorkspaceNotFoundError
|
||||
|
||||
Tenant does not exist.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| WorkspaceNotFoundError | | Tenant does not exist. | |
|
||||
Generated
+12
-4
@@ -578,7 +578,7 @@ Generate a new completion similar to an existing message (completion apps only).
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
### [GET] /messages/{message_id}/suggested-questions
|
||||
Get suggested follow-up questions after a message (chat apps only).
|
||||
Get suggested follow-up questions after a message (chat apps only). 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`.
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -591,10 +591,10 @@ Get suggested follow-up questions after a message (chat apps only).
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)<br> |
|
||||
| 400 | Bad Request - Not a chat app or feature disabled | **application/json**: [CompletionRequestError](#completionrequesterror)<br>[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)<br>[NotChatAppError](#notchatapperror)<br>[ProviderNotInitializeError](#providernotinitializeerror)<br>[ProviderQuotaExceededError](#providerquotaexceedederror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 400 | Bad Request - Not a chat app or app unavailable; `completion_request_error` when model invocation fails while counting history tokens. | **application/json**: [AppUnavailableError](#appunavailableerror)<br>[CompletionRequestError](#completionrequesterror)<br>[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)<br>[NotChatAppError](#notchatapperror)<br>[ProviderNotInitializeError](#providernotinitializeerror)<br>[ProviderQuotaExceededError](#providerquotaexceedederror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 401 | Unauthorized | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | Forbidden | **application/json**: [AppSuggestedQuestionsAfterAnswerDisabledError](#appsuggestedquestionsafteranswerdisablederror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | Message Not Found or Conversation Not Found | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | Forbidden - Access denied or suggested questions disabled | **application/json**: [AppSuggestedQuestionsAfterAnswerDisabledError](#appsuggestedquestionsafteranswerdisablederror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | App, End User, Message, or Conversation Not Found | **application/json**: [AgentVersionNotFoundError](#agentversionnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 500 | Internal Server Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
@@ -1020,6 +1020,14 @@ Agent has not been published. Please publish the Agent before using the web app.
|
||||
| tool_input | string | | No |
|
||||
| tool_labels | [AnyJSONValue](#anyjsonvalue) | Labels for tools used. | Yes |
|
||||
|
||||
#### AgentVersionNotFoundError
|
||||
|
||||
Agent config version not found.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentVersionNotFoundError | | Agent config version not found. | |
|
||||
|
||||
#### AnyJSONValue
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from typing import Any, cast, override
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import String, select
|
||||
from sqlalchemy import cast as sql_cast
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
|
||||
@@ -21,6 +22,7 @@ from services.app_definition_query_service import (
|
||||
AppParameterConfig,
|
||||
AppSiteConfiguration,
|
||||
AppToolIconSource,
|
||||
ServiceApiAppRecord,
|
||||
)
|
||||
from services.web_app_runtime_query_service import WebAppRuntimeRecord
|
||||
|
||||
@@ -118,6 +120,31 @@ class AppDefinitionQueryRepository(AppDefinitionQuery):
|
||||
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def get_service_api_record(self, app_id: str, *, tenant_id: str | None = None) -> ServiceApiAppRecord | None:
|
||||
with self._session_factory() as session:
|
||||
statement = (
|
||||
# Let admission reject abnormal historical statuses with a domain
|
||||
# error instead of failing enum deserialization before policy runs.
|
||||
select(App.id, App.tenant_id, App.mode, sql_cast(App.status, String), App.enable_api, Tenant.status)
|
||||
.outerjoin(Tenant, Tenant.id == App.tenant_id)
|
||||
.where(App.id == app_id)
|
||||
)
|
||||
if tenant_id is not None:
|
||||
statement = statement.where(App.tenant_id == tenant_id)
|
||||
row = session.execute(statement).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
resolved_id, tenant_id, mode, status, enable_api, tenant_status = row
|
||||
return ServiceApiAppRecord(
|
||||
app_id=resolved_id,
|
||||
tenant_id=tenant_id,
|
||||
mode=mode.value,
|
||||
status=status,
|
||||
enable_api=enable_api,
|
||||
tenant_status=tenant_status.value if tenant_status is not None else None,
|
||||
)
|
||||
|
||||
@override
|
||||
def get_mode(self, app_id: str) -> str | None:
|
||||
with self._session_factory() as session:
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Message ownership and history reads within the caller's bounded read session."""
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.memory.token_buffer_memory import PreparedHistory, TokenBufferMemory
|
||||
from models import Account, App, Conversation, EndUser, Message
|
||||
from models.model import AppModelConfig
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.entities.message_entities import MessageAccount, MessageActor
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import MessageActorNotFoundError, MessageNotExistsError
|
||||
from services.message_suggested_questions_service import SuggestedQuestionsContext
|
||||
|
||||
|
||||
class SuggestedQuestionsRecords(NamedTuple):
|
||||
app: App
|
||||
conversation: Conversation
|
||||
|
||||
|
||||
class MessageRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self._session: Session = session
|
||||
|
||||
def get_context(
|
||||
self,
|
||||
*,
|
||||
app_id: str,
|
||||
app_owner_tenant_id: str,
|
||||
expected_app_mode: str,
|
||||
actor: MessageActor,
|
||||
message_id: str,
|
||||
) -> SuggestedQuestionsRecords:
|
||||
app = self._session.scalar(select(App).where(App.id == app_id, App.tenant_id == app_owner_tenant_id))
|
||||
if app is None or app.mode != expected_app_mode:
|
||||
raise AppDefinitionUnavailableError(
|
||||
f"App {app_id} is unavailable in tenant {app_owner_tenant_id} with mode {expected_app_mode}"
|
||||
)
|
||||
|
||||
account_id = None
|
||||
end_user_id = None
|
||||
if isinstance(actor, MessageAccount):
|
||||
account_id = actor.account_id
|
||||
if self._session.get(Account, account_id) is None:
|
||||
raise MessageActorNotFoundError(f"Account {account_id} no longer exists")
|
||||
source = "console"
|
||||
else:
|
||||
end_user_id = actor.end_user_id
|
||||
end_user = self._session.scalar(
|
||||
select(EndUser).where(
|
||||
EndUser.id == end_user_id, EndUser.app_id == app_id, EndUser.tenant_id == app_owner_tenant_id
|
||||
)
|
||||
)
|
||||
if end_user is None:
|
||||
raise MessageActorNotFoundError(
|
||||
f"End user {end_user_id} does not exist for app {app_id} in tenant {app_owner_tenant_id}"
|
||||
)
|
||||
source = "api"
|
||||
|
||||
message = self._session.scalar(
|
||||
select(Message).where(
|
||||
Message.id == message_id,
|
||||
Message.app_id == app_id,
|
||||
Message.from_source == source,
|
||||
Message.from_account_id == account_id,
|
||||
Message.from_end_user_id == end_user_id,
|
||||
)
|
||||
)
|
||||
if message is None:
|
||||
raise MessageNotExistsError()
|
||||
|
||||
conversation = self._session.scalar(
|
||||
select(Conversation).where(
|
||||
Conversation.id == message.conversation_id,
|
||||
Conversation.app_id == app_id,
|
||||
Conversation.from_source == source,
|
||||
Conversation.from_account_id == account_id,
|
||||
Conversation.from_end_user_id == end_user_id,
|
||||
Conversation.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if conversation is None:
|
||||
raise ConversationNotExistsError()
|
||||
return SuggestedQuestionsRecords(app, conversation)
|
||||
|
||||
def get_model_config(self, *, app_id: str, config_id: str | None) -> AppModelConfig | None:
|
||||
"""Return the app-owned configuration; a missing pointer or row returns None."""
|
||||
if config_id is None:
|
||||
return None
|
||||
return self._session.scalar(
|
||||
select(AppModelConfig).where(AppModelConfig.id == config_id, AppModelConfig.app_id == app_id)
|
||||
)
|
||||
|
||||
def load_history(self, *, context: SuggestedQuestionsContext) -> PreparedHistory:
|
||||
# Model resolution separates the two read phases. Reload only the app
|
||||
# and conversation needed by the history reader, retaining their owner
|
||||
# scope without repeating actor and target-message admission queries.
|
||||
app = self._session.scalar(select(App).where(App.id == context.app_id, App.tenant_id == context.tenant_id))
|
||||
if app is None or app.mode != context.app_mode:
|
||||
raise AppDefinitionUnavailableError(
|
||||
f"App {context.app_id} is unavailable in tenant {context.tenant_id} with mode {context.app_mode}"
|
||||
)
|
||||
actor = context.actor
|
||||
account_id = actor.account_id if isinstance(actor, MessageAccount) else None
|
||||
end_user_id = None if isinstance(actor, MessageAccount) else actor.end_user_id
|
||||
conversation = self._session.scalar(
|
||||
select(Conversation).where(
|
||||
Conversation.id == context.conversation_id,
|
||||
Conversation.app_id == context.app_id,
|
||||
Conversation.from_source == ("console" if isinstance(actor, MessageAccount) else "api"),
|
||||
Conversation.from_account_id == account_id,
|
||||
Conversation.from_end_user_id == end_user_id,
|
||||
Conversation.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if conversation is None:
|
||||
raise ConversationNotExistsError()
|
||||
return TokenBufferMemory.load_history(
|
||||
conversation=conversation,
|
||||
app_record=app,
|
||||
session=self._session,
|
||||
message_limit=3,
|
||||
)
|
||||
@@ -6,6 +6,8 @@ from typing import Any, NamedTuple, Protocol
|
||||
|
||||
from core.app.app_config.common.parameters_mapping import AppParametersDict, get_parameters_from_feature_dict
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from services.errors.app import AppAbnormalStatusError, AppApiDisabledError
|
||||
from services.errors.workspace import WorkspaceArchivedError, WorkspaceNotFoundError
|
||||
|
||||
|
||||
class AppParameterConfig(NamedTuple):
|
||||
@@ -28,6 +30,17 @@ class AppDefinitionSummary(NamedTuple):
|
||||
author_name: str | None
|
||||
|
||||
|
||||
class ServiceApiAppRecord(NamedTuple):
|
||||
"""App admission state; a null tenant status means the workspace is missing."""
|
||||
|
||||
app_id: str
|
||||
tenant_id: str
|
||||
mode: str
|
||||
status: str
|
||||
enable_api: bool
|
||||
tenant_status: str | None
|
||||
|
||||
|
||||
class AppSiteConfiguration(NamedTuple):
|
||||
title: str
|
||||
chat_color_theme: str | None
|
||||
@@ -49,6 +62,8 @@ class AppSiteConfiguration(NamedTuple):
|
||||
class AppDefinitionQuery(Protocol):
|
||||
def get_mode(self, app_id: str) -> str | None: ...
|
||||
|
||||
def get_service_api_record(self, app_id: str, *, tenant_id: str | None = None) -> ServiceApiAppRecord | None: ...
|
||||
|
||||
def get_published_parameter_config(
|
||||
self,
|
||||
app_id: str,
|
||||
@@ -84,6 +99,29 @@ class AppDefinitionQueryService:
|
||||
self._definitions = definitions
|
||||
self._builtin_icon_url_prefix = builtin_icon_url_prefix
|
||||
|
||||
def get_service_api_app(self, app_id: str | None, *, tenant_id: str | None = None) -> ServiceApiAppRecord:
|
||||
"""Return an app admitted for Service API access, or raise a domain error.
|
||||
|
||||
``None`` means the API token no longer references an app. The repository
|
||||
closes its read session before this policy runs or an end user is provisioned.
|
||||
Resource-token grants provide ``tenant_id`` to constrain the lookup to their
|
||||
workspace; ``None`` retains legacy app tokens' app-ID-only lookup.
|
||||
"""
|
||||
if app_id is None:
|
||||
raise AppDefinitionUnavailableError("The app no longer exists.")
|
||||
app = self._definitions.get_service_api_record(app_id, tenant_id=tenant_id)
|
||||
if app is None:
|
||||
raise AppDefinitionUnavailableError("The app no longer exists.")
|
||||
if app.status != "normal":
|
||||
raise AppAbnormalStatusError("The app's status is abnormal.")
|
||||
if not app.enable_api:
|
||||
raise AppApiDisabledError("The app's API service has been disabled.")
|
||||
if app.tenant_status is None:
|
||||
raise WorkspaceNotFoundError("Tenant does not exist.")
|
||||
if app.tenant_status == "archive":
|
||||
raise WorkspaceArchivedError("The workspace's status is archived.")
|
||||
return app
|
||||
|
||||
def get_mode(self, app_id: str) -> str:
|
||||
mode = self._definitions.get_mode(app_id)
|
||||
if mode is None:
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Caller identities shared by message operations, independent of invocation origin."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MessageAccount:
|
||||
account_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MessageEndUser:
|
||||
end_user_id: str
|
||||
|
||||
|
||||
type MessageActor = MessageAccount | MessageEndUser
|
||||
@@ -14,6 +14,14 @@ class MoreLikeThisDisabledError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AppAbnormalStatusError(Exception):
|
||||
"""The app is not in its normal operating state."""
|
||||
|
||||
|
||||
class AppApiDisabledError(Exception):
|
||||
"""The app owner has disabled Service API access."""
|
||||
|
||||
|
||||
class WorkflowHashNotEqualError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from services.errors.base import BaseServiceError
|
||||
|
||||
|
||||
class MessageActorNotFoundError(LookupError):
|
||||
"""The account is missing, or the end user is outside the admitted app and tenant scope."""
|
||||
|
||||
|
||||
class FirstMessageNotExistsError(BaseServiceError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,38 +1,18 @@
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
|
||||
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, get_credit_usage_app_type
|
||||
from core.llm_generator.llm_generator import LLMGenerator
|
||||
from core.memory.token_buffer_memory import TokenBufferMemory
|
||||
from core.model_context import use_credit_usage_metadata
|
||||
from core.model_manager import ModelInstance, ModelManager
|
||||
from core.ops.entities.trace_entity import TraceTaskName
|
||||
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
|
||||
from core.ops.utils import measure_time
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from models import Account
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import FeedbackFromSource, FeedbackRating
|
||||
from models.model import (
|
||||
App,
|
||||
AppMode,
|
||||
AppModelConfig,
|
||||
Conversation,
|
||||
EndUser,
|
||||
Message,
|
||||
MessageFeedback,
|
||||
SuggestedQuestionsAfterAnswerConfig,
|
||||
load_annotation_reply_config,
|
||||
)
|
||||
from repositories.execution_extra_content_repository import ExecutionExtraContentRepository
|
||||
from repositories.sqlalchemy_execution_extra_content_repository import (
|
||||
@@ -43,9 +23,7 @@ from services.errors.message import (
|
||||
FirstMessageNotExistsError,
|
||||
LastMessageNotExistsError,
|
||||
MessageNotExistsError,
|
||||
SuggestedQuestionsAfterAnswerDisabledError,
|
||||
)
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,56 +45,7 @@ def attach_message_extra_contents(messages: Sequence[Message]) -> None:
|
||||
message.set_extra_contents([content.model_dump(mode="json", exclude_none=True) for content in contents])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SuggestedQuestionsContext:
|
||||
"""Validated configuration and loaded conversation for the legacy history reader.
|
||||
|
||||
The conversation's scalar fields remain usable after the preparation session
|
||||
closes. History loading must use an explicit session, never ORM properties.
|
||||
"""
|
||||
|
||||
app_id: str
|
||||
tenant_id: str
|
||||
app_mode: str
|
||||
message_id: str
|
||||
conversation: Conversation
|
||||
instruction_prompt: str | None
|
||||
model_config: object | None
|
||||
|
||||
|
||||
class MessageService:
|
||||
@classmethod
|
||||
def _get_agent_suggested_questions_config(
|
||||
cls,
|
||||
*,
|
||||
app_model: App,
|
||||
user: Account | EndUser,
|
||||
conversation: Conversation,
|
||||
invoke_from: InvokeFrom,
|
||||
session: Session,
|
||||
) -> SuggestedQuestionsAfterAnswerConfig:
|
||||
from services.agent.runtime_config_service import AgentRuntimeConfigService
|
||||
|
||||
agent_soul = AgentRuntimeConfigService(session).resolve_conversation_soul(
|
||||
app_model=app_model,
|
||||
conversation=conversation,
|
||||
account_id=user.id if isinstance(user, Account) else None,
|
||||
use_debug_draft=invoke_from == InvokeFrom.DEBUGGER,
|
||||
)
|
||||
app_model_config = (
|
||||
session.get(AppModelConfig, app_model.app_model_config_id) if app_model.app_model_config_id else None
|
||||
)
|
||||
annotation_reply = load_annotation_reply_config(session, app_model.id) if app_model_config else None
|
||||
features = merge_agent_app_features(
|
||||
agent_soul=agent_soul or AgentSoulConfig(),
|
||||
app_model_config=app_model_config,
|
||||
annotation_reply=annotation_reply,
|
||||
)
|
||||
suggested_questions = features.get("suggested_questions_after_answer")
|
||||
if not isinstance(suggested_questions, dict) or not suggested_questions.get("enabled", False):
|
||||
raise SuggestedQuestionsAfterAnswerDisabledError()
|
||||
return cast(SuggestedQuestionsAfterAnswerConfig, suggested_questions)
|
||||
|
||||
@classmethod
|
||||
def pagination_by_first_id(
|
||||
cls,
|
||||
@@ -351,157 +280,3 @@ class MessageService:
|
||||
raise MessageNotExistsError()
|
||||
|
||||
return message
|
||||
|
||||
@classmethod
|
||||
def prepare_suggested_questions_after_answer(
|
||||
cls,
|
||||
app_model: App,
|
||||
user: Account | EndUser | None,
|
||||
message_id: str,
|
||||
invoke_from: InvokeFrom,
|
||||
*,
|
||||
session: Session,
|
||||
) -> SuggestedQuestionsContext | None:
|
||||
"""Read authorization and feature configuration; None means no published workflow."""
|
||||
if not user:
|
||||
raise ValueError("user cannot be None")
|
||||
|
||||
message = cls.get_message(app_model=app_model, user=user, message_id=message_id, session=session)
|
||||
|
||||
conversation = ConversationService.get_conversation(
|
||||
app_model=app_model, conversation_id=message.conversation_id, user=user, session=session
|
||||
)
|
||||
|
||||
suggested_questions_after_answer_config: SuggestedQuestionsAfterAnswerConfig = {"enabled": False}
|
||||
|
||||
if app_model.mode == AppMode.ADVANCED_CHAT:
|
||||
workflow_service = WorkflowService()
|
||||
if invoke_from == InvokeFrom.DEBUGGER:
|
||||
workflow = workflow_service.get_draft_workflow(app_model=app_model, session=session)
|
||||
else:
|
||||
workflow = workflow_service.get_published_workflow(app_model=app_model, session=session)
|
||||
|
||||
if workflow is None:
|
||||
return None
|
||||
|
||||
app_config = AdvancedChatAppConfigManager.get_app_config(app_model=app_model, workflow=workflow)
|
||||
|
||||
if not app_config.additional_features:
|
||||
raise ValueError("Additional features not found")
|
||||
|
||||
if not app_config.additional_features.suggested_questions_after_answer:
|
||||
raise SuggestedQuestionsAfterAnswerDisabledError()
|
||||
|
||||
suggested_questions_after_answer = workflow.features_dict.get("suggested_questions_after_answer")
|
||||
if isinstance(suggested_questions_after_answer, dict):
|
||||
suggested_questions_after_answer_config = cast(
|
||||
SuggestedQuestionsAfterAnswerConfig, suggested_questions_after_answer
|
||||
)
|
||||
elif app_model.mode == AppMode.AGENT:
|
||||
suggested_questions_after_answer_config = cls._get_agent_suggested_questions_config(
|
||||
app_model=app_model,
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
invoke_from=invoke_from,
|
||||
session=session,
|
||||
)
|
||||
else:
|
||||
if not conversation.override_model_configs:
|
||||
app_model_config = session.scalar(
|
||||
select(AppModelConfig)
|
||||
.where(AppModelConfig.id == conversation.app_model_config_id, AppModelConfig.app_id == app_model.id)
|
||||
.limit(1)
|
||||
)
|
||||
else:
|
||||
app_model_config = AppModelConfig(
|
||||
app_id=app_model.id,
|
||||
)
|
||||
# Reuse Conversation.model_config so suggested-questions reads the same
|
||||
# compatibility-normalized config as the rest of the message flow.
|
||||
app_model_config = app_model_config.from_model_config_dict(
|
||||
conversation.model_config_with_session(session=session)
|
||||
)
|
||||
if not app_model_config:
|
||||
raise ValueError("did not find app model config")
|
||||
|
||||
suggested_questions_after_answer_config = app_model_config.suggested_questions_after_answer_dict
|
||||
if suggested_questions_after_answer_config.get("enabled", False) is False:
|
||||
raise SuggestedQuestionsAfterAnswerDisabledError()
|
||||
|
||||
instruction_prompt = suggested_questions_after_answer_config.get("prompt")
|
||||
if not isinstance(instruction_prompt, str) or not instruction_prompt.strip():
|
||||
instruction_prompt = None
|
||||
return SuggestedQuestionsContext(
|
||||
app_id=app_model.id,
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_mode=app_model.mode,
|
||||
message_id=message_id,
|
||||
conversation=conversation,
|
||||
instruction_prompt=instruction_prompt,
|
||||
model_config=suggested_questions_after_answer_config.get("model"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_suggested_questions_history_model(*, tenant_id: str) -> ModelInstance | None:
|
||||
"""Resolve the history token counter; None preserves the no-model fallback."""
|
||||
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
|
||||
try:
|
||||
return model_manager.get_default_model_instance(tenant_id=tenant_id, model_type=ModelType.LLM)
|
||||
except Exception:
|
||||
logger.exception("Failed to resolve the history model for suggested questions")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_suggested_questions_after_answer(
|
||||
cls,
|
||||
app_model: App,
|
||||
user: Account | EndUser | None,
|
||||
message_id: str,
|
||||
invoke_from: InvokeFrom,
|
||||
*,
|
||||
session: Session,
|
||||
) -> list[str]:
|
||||
"""Compatibility entry point; the caller still owns its request session.
|
||||
|
||||
Admitted callers use MessageSuggestedQuestionsRuntime to release query
|
||||
and model-resolution sessions before attachment and provider I/O.
|
||||
"""
|
||||
context = cls.prepare_suggested_questions_after_answer(
|
||||
app_model=app_model, user=user, message_id=message_id, invoke_from=invoke_from, session=session
|
||||
)
|
||||
if context is None:
|
||||
return []
|
||||
model_instance = cls.get_suggested_questions_history_model(tenant_id=context.tenant_id)
|
||||
if model_instance is None:
|
||||
return []
|
||||
memory = TokenBufferMemory(conversation=context.conversation, model_instance=model_instance)
|
||||
histories = memory.get_history_prompt_text(max_token_limit=3000, message_limit=3)
|
||||
|
||||
with (
|
||||
measure_time() as timer,
|
||||
use_credit_usage_metadata({"app_type": get_credit_usage_app_type(context.app_mode)}),
|
||||
):
|
||||
questions = list(
|
||||
LLMGenerator.generate_suggested_questions_after_answer(
|
||||
tenant_id=context.tenant_id,
|
||||
histories=histories,
|
||||
instruction_prompt=context.instruction_prompt,
|
||||
model_config=context.model_config,
|
||||
)
|
||||
)
|
||||
cls.trace_suggested_questions(context=context, questions=questions, timer=timer)
|
||||
return questions
|
||||
|
||||
@staticmethod
|
||||
def trace_suggested_questions(
|
||||
*, context: SuggestedQuestionsContext, questions: list[str], timer: Mapping[str, datetime | None]
|
||||
) -> None:
|
||||
trace_manager = TraceQueueManager(app_id=context.app_id)
|
||||
trace_manager.add_trace_task(
|
||||
TraceTask(
|
||||
TraceTaskName.SUGGESTED_QUESTION_TRACE,
|
||||
message_id=context.message_id,
|
||||
suggested_question=questions,
|
||||
timer=timer,
|
||||
)
|
||||
)
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Run suggested questions with bounded query and model-resolution sessions.
|
||||
|
||||
Keep legacy configuration, history and provider policy with their existing
|
||||
owners. This bridge controls their lifetimes until those owners accept only
|
||||
framework-neutral inputs; it never closes the caller's scoped session.
|
||||
"""
|
||||
|
||||
from typing import override
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, get_credit_usage_app_type
|
||||
from core.llm_generator.llm_generator import LLMGenerator
|
||||
from core.memory.token_buffer_memory import TokenBufferMemory
|
||||
from core.model_context import use_credit_usage_metadata
|
||||
from core.ops.utils import measure_time
|
||||
from extensions.ext_database import db
|
||||
from models import Account, App
|
||||
from models.model import EndUser
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.message_service import MessageService
|
||||
from services.message_suggested_questions_service import (
|
||||
MessageSuggestedQuestions,
|
||||
SuggestedQuestionsAccount,
|
||||
SuggestedQuestionsActor,
|
||||
SuggestedQuestionsActorNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
class MessageSuggestedQuestionsRuntime(MessageSuggestedQuestions):
|
||||
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory: sessionmaker[Session] = session_factory
|
||||
|
||||
@override
|
||||
def get_suggested_questions(
|
||||
self,
|
||||
*,
|
||||
app_id: str,
|
||||
app_owner_tenant_id: str,
|
||||
expected_app_mode: str,
|
||||
actor: SuggestedQuestionsActor,
|
||||
message_id: str,
|
||||
) -> list[str]:
|
||||
with self._session_factory(expire_on_commit=False) as session:
|
||||
app_model = session.get(App, app_id)
|
||||
if app_model is None or app_model.tenant_id != app_owner_tenant_id:
|
||||
raise AppDefinitionUnavailableError(f"App {app_id} no longer exists in tenant {app_owner_tenant_id}")
|
||||
if app_model.mode != expected_app_mode:
|
||||
raise AppDefinitionUnavailableError(
|
||||
f"App {app_id} mode changed from {expected_app_mode} to {app_model.mode} after admission"
|
||||
)
|
||||
|
||||
user: Account | EndUser | None
|
||||
if isinstance(actor, SuggestedQuestionsAccount):
|
||||
user = session.get(Account, actor.account_id)
|
||||
if user is None:
|
||||
raise SuggestedQuestionsActorNotFoundError(f"Account {actor.account_id} no longer exists")
|
||||
else:
|
||||
user = session.scalar(
|
||||
select(EndUser).where(
|
||||
EndUser.id == actor.end_user_id,
|
||||
EndUser.app_id == app_id,
|
||||
EndUser.tenant_id == app_owner_tenant_id,
|
||||
)
|
||||
)
|
||||
if user is None:
|
||||
raise SuggestedQuestionsActorNotFoundError(
|
||||
f"End user {actor.end_user_id} does not exist for app {app_id} in tenant {app_owner_tenant_id}"
|
||||
)
|
||||
|
||||
context = MessageService.prepare_suggested_questions_after_answer(
|
||||
app_model=app_model,
|
||||
user=user,
|
||||
message_id=message_id,
|
||||
invoke_from=InvokeFrom(actor.invoke_from),
|
||||
session=session,
|
||||
)
|
||||
if context is None:
|
||||
return []
|
||||
|
||||
# Legacy model resolution, quota and tracing use db.session and may
|
||||
# commit. Own one isolated scope so none can commit or close the caller's
|
||||
# session. Release its sessions explicitly between phases; teardown also
|
||||
# handles early returns and exceptions.
|
||||
with current_app.app_context():
|
||||
history_model = MessageService.get_suggested_questions_history_model(tenant_id=context.tenant_id)
|
||||
db.session.remove()
|
||||
if history_model is None:
|
||||
return []
|
||||
|
||||
# Resolve the history model first to preserve the no-model fallback
|
||||
# even when old history contains invalid file configuration.
|
||||
with self._session_factory(expire_on_commit=False) as session:
|
||||
history = TokenBufferMemory.load_history(
|
||||
conversation=context.conversation,
|
||||
app_record=app_model,
|
||||
session=session,
|
||||
message_limit=3,
|
||||
)
|
||||
|
||||
histories = history.get_prompt_text(model_instance=history_model, max_token_limit=3000)
|
||||
|
||||
with (
|
||||
measure_time() as timer,
|
||||
use_credit_usage_metadata({"app_type": get_credit_usage_app_type(context.app_mode)}),
|
||||
):
|
||||
model = LLMGenerator.prepare_suggested_questions_model(
|
||||
tenant_id=context.tenant_id, model_config=context.model_config
|
||||
)
|
||||
db.session.remove()
|
||||
questions = (
|
||||
list(
|
||||
LLMGenerator.invoke_suggested_questions_after_answer(
|
||||
prepared_model=model,
|
||||
histories=histories,
|
||||
instruction_prompt=context.instruction_prompt,
|
||||
)
|
||||
)
|
||||
if model is not None
|
||||
else []
|
||||
)
|
||||
|
||||
# Generation can return [] after catching an error. Tracing must not
|
||||
# inherit an open or failed transaction from that invocation.
|
||||
db.session.remove()
|
||||
MessageService.trace_suggested_questions(context=context, questions=questions, timer=timer)
|
||||
return questions
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Generate suggested questions after all conversation query sessions close."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Generator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from core.app.entities.app_invoke_entities import CreditUsageCreatedBy, get_credit_usage_app_type
|
||||
from core.llm_generator.llm_generator import _normalize_completion_params
|
||||
from core.llm_generator.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser
|
||||
from core.model_context import use_credit_usage_metadata, with_credit_usage_created_by
|
||||
from core.model_manager import ModelInstance, ModelManager
|
||||
from core.ops.entities.trace_entity import TraceTaskName
|
||||
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
|
||||
from core.ops.utils import measure_time
|
||||
from core.plugin.impl.base import use_plugin_daemon_request_timeout
|
||||
from core.prompt.utils.prompt_template_parser import PromptTemplateParser
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResult
|
||||
from graphon.model_runtime.entities.message_entities import PromptMessage, UserPromptMessage
|
||||
from graphon.model_runtime.entities.model_entities import ModelType, ParameterType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.memory.token_buffer_memory import PreparedHistory
|
||||
from services.message_suggested_questions_service import SuggestedQuestionsContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SUGGESTED_QUESTIONS_MAX_TOKENS = 256
|
||||
_SUGGESTED_QUESTIONS_TIMEOUT_SECONDS = 30.0
|
||||
_LOW_REASONING_EFFORTS = ("none", "minimal", "low")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedSuggestedQuestionsModel:
|
||||
"""Resolved model credentials; ``None`` parameters select the default-model tuning."""
|
||||
|
||||
model_instance: ModelInstance
|
||||
completion_params: dict[str, object] | None
|
||||
|
||||
|
||||
def _default_suggested_questions_model_parameters(model_instance: ModelInstance) -> dict[str, object]:
|
||||
"""Build a low-latency parameter set for the workspace default model."""
|
||||
parameters: dict[str, object] = {
|
||||
"max_tokens": _SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
|
||||
try:
|
||||
model_schema = model_instance.get_model_schema()
|
||||
except Exception:
|
||||
logger.warning("Failed to inspect the default model schema for suggested questions", exc_info=True)
|
||||
return parameters
|
||||
|
||||
parameter_rules = {rule.name: rule for rule in model_schema.parameter_rules}
|
||||
thinking_rule = parameter_rules.get("thinking")
|
||||
if thinking_rule is not None:
|
||||
if thinking_rule.type == ParameterType.BOOLEAN:
|
||||
parameters["thinking"] = False
|
||||
return parameters
|
||||
if "disabled" in thinking_rule.options:
|
||||
parameters["thinking"] = "disabled"
|
||||
return parameters
|
||||
|
||||
reasoning_effort_rule = parameter_rules.get("reasoning_effort")
|
||||
if reasoning_effort_rule is not None:
|
||||
for effort in _LOW_REASONING_EFFORTS:
|
||||
if effort in reasoning_effort_rule.options:
|
||||
parameters["reasoning_effort"] = effort
|
||||
break
|
||||
|
||||
return parameters
|
||||
|
||||
|
||||
class SuggestedQuestionsGenerator:
|
||||
"""Own model resolution, detached-history rendering, generation and tracing."""
|
||||
|
||||
@contextmanager
|
||||
def prepare(
|
||||
self,
|
||||
*,
|
||||
context: "SuggestedQuestionsContext",
|
||||
instruction_prompt: str | None,
|
||||
model_config: object | None,
|
||||
) -> Generator[Callable[["PreparedHistory"], list[str]] | None]:
|
||||
"""Yield a request-local generator, or None if its history model is unavailable."""
|
||||
# Model resolution and tracing may use or commit the scoped session.
|
||||
# Isolate them from the caller, retaining one app context across phases.
|
||||
with current_app.app_context():
|
||||
history_model = self._get_history_model(tenant_id=context.tenant_id)
|
||||
db.session.remove()
|
||||
if history_model is None:
|
||||
yield None
|
||||
else:
|
||||
yield partial(
|
||||
self.generate,
|
||||
history_model=history_model,
|
||||
context=context,
|
||||
instruction_prompt=instruction_prompt,
|
||||
model_config=model_config,
|
||||
)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
history: "PreparedHistory",
|
||||
*,
|
||||
history_model: ModelInstance,
|
||||
context: "SuggestedQuestionsContext",
|
||||
instruction_prompt: str | None,
|
||||
model_config: object | None,
|
||||
) -> list[str]:
|
||||
"""Consume detached history within the isolation scope opened by prepare()."""
|
||||
histories = history.get_prompt_text(model_instance=history_model, max_token_limit=3000)
|
||||
|
||||
with (
|
||||
measure_time() as timer,
|
||||
use_credit_usage_metadata({"app_type": get_credit_usage_app_type(context.app_mode)}),
|
||||
):
|
||||
model = self._prepare_model(tenant_id=context.tenant_id, model_config=model_config)
|
||||
db.session.remove()
|
||||
questions = (
|
||||
list(self._invoke(prepared_model=model, histories=histories, instruction_prompt=instruction_prompt))
|
||||
if model is not None
|
||||
else []
|
||||
)
|
||||
|
||||
# Invocation catches provider failures, including failed database
|
||||
# transactions. Tracing always starts with a fresh scoped session.
|
||||
db.session.remove()
|
||||
TraceQueueManager(app_id=context.app_id).add_trace_task(
|
||||
TraceTask(
|
||||
TraceTaskName.SUGGESTED_QUESTION_TRACE,
|
||||
message_id=context.message_id,
|
||||
suggested_question=questions,
|
||||
timer=timer,
|
||||
)
|
||||
)
|
||||
return questions
|
||||
|
||||
@staticmethod
|
||||
def _get_history_model(*, tenant_id: str) -> ModelInstance | None:
|
||||
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
|
||||
try:
|
||||
return model_manager.get_default_model_instance(tenant_id=tenant_id, model_type=ModelType.LLM)
|
||||
except Exception:
|
||||
logger.exception("Failed to resolve the history model for suggested questions")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@with_credit_usage_created_by(CreditUsageCreatedBy.SUGGESTED_QUESTIONS)
|
||||
def _prepare_model(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
*,
|
||||
model_config: object | None = None,
|
||||
) -> PreparedSuggestedQuestionsModel | None:
|
||||
"""Resolve the model before its caller releases database sessions.
|
||||
|
||||
``None`` means no usable configured or default model was found. Schema
|
||||
inspection and generation belong to invocation, after the read phase.
|
||||
"""
|
||||
try:
|
||||
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
|
||||
configured_model = cast(dict[str, object], model_config) if isinstance(model_config, dict) else {}
|
||||
provider = configured_model.get("provider")
|
||||
model_name = configured_model.get("name")
|
||||
use_configured_model = False
|
||||
|
||||
if isinstance(provider, str) and provider and isinstance(model_name, str) and model_name:
|
||||
try:
|
||||
model_instance = model_manager.get_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
provider=provider,
|
||||
model=model_name,
|
||||
)
|
||||
use_configured_model = True
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to use configured suggested-questions model %s/%s, fallback to default model",
|
||||
provider,
|
||||
model_name,
|
||||
exc_info=True,
|
||||
)
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
else:
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to resolve the suggested-questions model")
|
||||
return None
|
||||
|
||||
completion_params: dict[str, object] | None = None
|
||||
if use_configured_model:
|
||||
configured_completion_params = configured_model.get("completion_params")
|
||||
completion_params = (
|
||||
dict(configured_completion_params) if isinstance(configured_completion_params, dict) else {}
|
||||
)
|
||||
return PreparedSuggestedQuestionsModel(
|
||||
model_instance=model_instance,
|
||||
completion_params=completion_params,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@with_credit_usage_created_by(CreditUsageCreatedBy.SUGGESTED_QUESTIONS)
|
||||
def _invoke(
|
||||
cls,
|
||||
prepared_model: PreparedSuggestedQuestionsModel,
|
||||
histories: str,
|
||||
*,
|
||||
instruction_prompt: str | None = None,
|
||||
) -> Sequence[str]:
|
||||
"""Generate with an already resolved model without repeating model resolution."""
|
||||
output_parser = SuggestedQuestionsAfterAnswerOutputParser(instruction_prompt=instruction_prompt)
|
||||
format_instructions = output_parser.get_format_instructions()
|
||||
prompt_template = PromptTemplateParser(template="{{histories}}\n{{format_instructions}}\nquestions:\n")
|
||||
prompt = prompt_template.format({"histories": histories, "format_instructions": format_instructions})
|
||||
|
||||
prompt_messages: list[PromptMessage] = [UserPromptMessage(content=prompt)]
|
||||
|
||||
questions: Sequence[str] = []
|
||||
|
||||
try:
|
||||
model_parameters: dict[str, object]
|
||||
stop: list[str]
|
||||
model_instance = prepared_model.model_instance
|
||||
if prepared_model.completion_params is not None:
|
||||
model_parameters, stop = _normalize_completion_params(prepared_model.completion_params)
|
||||
else:
|
||||
# Default-model generation keeps the built-in suggested-questions tuning.
|
||||
model_parameters = _default_suggested_questions_model_parameters(model_instance)
|
||||
stop = []
|
||||
|
||||
with use_plugin_daemon_request_timeout(_SUGGESTED_QUESTIONS_TIMEOUT_SECONDS):
|
||||
response: LLMResult = model_instance.invoke_llm(
|
||||
prompt_messages=list(prompt_messages),
|
||||
model_parameters=model_parameters,
|
||||
stop=stop,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
text_content = response.message.get_text_content()
|
||||
questions = output_parser.parse(text_content) if text_content else []
|
||||
except Exception:
|
||||
logger.exception("Failed to generate suggested questions after answer")
|
||||
questions = []
|
||||
|
||||
return questions
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Select message configuration using the existing Agent and Workflow policy owners.
|
||||
|
||||
Each operation owns a short read session. Only scalar configuration or detached
|
||||
history leaves this module; model resolution and attachment I/O happen afterwards.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from copy import deepcopy
|
||||
from types import MappingProxyType
|
||||
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.app_config.features.suggested_questions_after_answer.manager import (
|
||||
SuggestedQuestionsAfterAnswerConfigManager,
|
||||
)
|
||||
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
|
||||
from core.memory.token_buffer_memory import PreparedHistory
|
||||
from models import AppMode
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import AppModelConfig, load_annotation_reply_config
|
||||
from repositories.message_repository import MessageRepository, SuggestedQuestionsRecords
|
||||
from services.agent.runtime_config_service import AgentRuntimeConfigService
|
||||
from services.entities.message_entities import MessageAccount, MessageActor
|
||||
from services.message_suggested_questions_service import (
|
||||
SuggestedQuestionsContext,
|
||||
SuggestedQuestionsInvokeFrom,
|
||||
)
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
|
||||
class SuggestedQuestionsQuery:
|
||||
"""Coordinate repository reads with the existing ORM-based config readers.
|
||||
|
||||
This query owns each short session because AgentRuntimeConfigService,
|
||||
WorkflowService and conversation config helpers still need a caller session.
|
||||
The composition root supplies repository_factory to select the repository;
|
||||
it borrows that same session rather than opening another one.
|
||||
|
||||
TODO: Move session ownership into repositories once those config readers
|
||||
expose detached results, keeping their configuration policies in services.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_factory: sessionmaker[Session],
|
||||
repository_factory: Callable[[Session], MessageRepository],
|
||||
) -> None:
|
||||
self._session_factory: sessionmaker[Session] = session_factory
|
||||
self._repository_factory: Callable[[Session], MessageRepository] = repository_factory
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
*,
|
||||
app_id: str,
|
||||
app_owner_tenant_id: str,
|
||||
expected_app_mode: str,
|
||||
actor: MessageActor,
|
||||
invoke_from: SuggestedQuestionsInvokeFrom,
|
||||
message_id: str,
|
||||
) -> SuggestedQuestionsContext | None:
|
||||
with self._session_factory(expire_on_commit=False) as session:
|
||||
repository = self._repository_factory(session)
|
||||
records = repository.get_context(
|
||||
app_id=app_id,
|
||||
app_owner_tenant_id=app_owner_tenant_id,
|
||||
expected_app_mode=expected_app_mode,
|
||||
actor=actor,
|
||||
message_id=message_id,
|
||||
)
|
||||
config = self._configuration(
|
||||
session=session, repository=repository, records=records, actor=actor, invoke_from=invoke_from
|
||||
)
|
||||
if config is None:
|
||||
return None
|
||||
return SuggestedQuestionsContext(
|
||||
app_id=app_id,
|
||||
tenant_id=app_owner_tenant_id,
|
||||
app_mode=records.app.mode,
|
||||
message_id=message_id,
|
||||
conversation_id=records.conversation.id,
|
||||
actor=actor,
|
||||
invoke_from=invoke_from,
|
||||
config=MappingProxyType(deepcopy(dict(config))),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _configuration(
|
||||
*,
|
||||
session: Session,
|
||||
repository: MessageRepository,
|
||||
records: SuggestedQuestionsRecords,
|
||||
actor: MessageActor,
|
||||
invoke_from: SuggestedQuestionsInvokeFrom,
|
||||
) -> Mapping[str, object] | None:
|
||||
app, conversation = records
|
||||
if app.mode == AppMode.ADVANCED_CHAT:
|
||||
workflows = WorkflowService()
|
||||
workflow = (
|
||||
workflows.get_draft_workflow(app_model=app, session=session)
|
||||
if invoke_from == "debugger"
|
||||
else workflows.get_published_workflow(app_model=app, session=session)
|
||||
)
|
||||
if workflow is None:
|
||||
return None
|
||||
# features_dict mutates the workflow during compatibility normalization.
|
||||
# This is a query: use the same normalization without rewriting stored JSON.
|
||||
features = workflow.normalized_features_dict
|
||||
config = features.get("suggested_questions_after_answer")
|
||||
return {
|
||||
**(config if isinstance(config, dict) else {}),
|
||||
"enabled": SuggestedQuestionsAfterAnswerConfigManager.convert(features),
|
||||
}
|
||||
|
||||
if app.mode == AppMode.AGENT:
|
||||
soul = AgentRuntimeConfigService(session).resolve_conversation_soul(
|
||||
app_model=app,
|
||||
conversation=conversation,
|
||||
account_id=actor.account_id if isinstance(actor, MessageAccount) else None,
|
||||
use_debug_draft=invoke_from == "debugger",
|
||||
)
|
||||
model_config = repository.get_model_config(app_id=app.id, config_id=app.app_model_config_id)
|
||||
annotation_reply = load_annotation_reply_config(session, app.id) if model_config else None
|
||||
features = merge_agent_app_features(
|
||||
agent_soul=soul or AgentSoulConfig(),
|
||||
app_model_config=model_config,
|
||||
annotation_reply=annotation_reply,
|
||||
)
|
||||
config = features.get("suggested_questions_after_answer")
|
||||
return config if isinstance(config, dict) else {}
|
||||
|
||||
if conversation.override_model_configs:
|
||||
model_config = AppModelConfig(app_id=app.id).from_model_config_dict(
|
||||
conversation.model_config_with_session(session=session)
|
||||
)
|
||||
else:
|
||||
model_config = repository.get_model_config(app_id=app.id, config_id=conversation.app_model_config_id)
|
||||
if model_config is None:
|
||||
raise ValueError("did not find app model config")
|
||||
return model_config.suggested_questions_after_answer_dict
|
||||
|
||||
def load_history(self, context: SuggestedQuestionsContext) -> PreparedHistory:
|
||||
with self._session_factory(expire_on_commit=False) as session:
|
||||
return self._repository_factory(session).load_history(context=context)
|
||||
@@ -4,27 +4,15 @@ Callers own access admission, supported app modes, quotas and HTTP errors.
|
||||
The app owner tenant is independent of an account's active workspace.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from services.entities.message_entities import MessageActor
|
||||
from services.errors.message import SuggestedQuestionsAfterAnswerDisabledError
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SuggestedQuestionsAccount:
|
||||
account_id: str
|
||||
invoke_from: Literal["explore", "debugger"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SuggestedQuestionsEndUser:
|
||||
end_user_id: str
|
||||
invoke_from: Literal["web-app", "service-api"]
|
||||
|
||||
|
||||
type SuggestedQuestionsActor = SuggestedQuestionsAccount | SuggestedQuestionsEndUser
|
||||
|
||||
|
||||
class SuggestedQuestionsActorNotFoundError(LookupError):
|
||||
"""The account is missing, or the end user is outside the admitted app scope."""
|
||||
type SuggestedQuestionsInvokeFrom = Literal["explore", "debugger", "web-app", "service-api"]
|
||||
|
||||
|
||||
class MessageSuggestedQuestions(Protocol):
|
||||
@@ -34,6 +22,110 @@ class MessageSuggestedQuestions(Protocol):
|
||||
app_id: str,
|
||||
app_owner_tenant_id: str,
|
||||
expected_app_mode: str,
|
||||
actor: SuggestedQuestionsActor,
|
||||
actor: MessageActor,
|
||||
invoke_from: SuggestedQuestionsInvokeFrom,
|
||||
message_id: str,
|
||||
) -> list[str]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SuggestedQuestionsContext:
|
||||
"""An authorized message and its selected configuration, without ORM state."""
|
||||
|
||||
app_id: str
|
||||
tenant_id: str
|
||||
app_mode: str
|
||||
message_id: str
|
||||
conversation_id: str
|
||||
actor: MessageActor
|
||||
invoke_from: SuggestedQuestionsInvokeFrom
|
||||
config: Mapping[str, object]
|
||||
|
||||
|
||||
class SuggestedQuestionsContextQuery[HistoryT](Protocol):
|
||||
def prepare(
|
||||
self,
|
||||
*,
|
||||
app_id: str,
|
||||
app_owner_tenant_id: str,
|
||||
expected_app_mode: str,
|
||||
actor: MessageActor,
|
||||
invoke_from: SuggestedQuestionsInvokeFrom,
|
||||
message_id: str,
|
||||
) -> SuggestedQuestionsContext | None:
|
||||
"""Return message configuration, or None when there is no selected workflow."""
|
||||
...
|
||||
|
||||
def load_history(self, *, context: SuggestedQuestionsContext) -> HistoryT:
|
||||
"""Return detached history after closing its query session."""
|
||||
...
|
||||
|
||||
|
||||
class SuggestedQuestionsGeneration[HistoryT](Protocol):
|
||||
def prepare(
|
||||
self,
|
||||
*,
|
||||
context: SuggestedQuestionsContext,
|
||||
instruction_prompt: str | None,
|
||||
model_config: object | None,
|
||||
) -> AbstractContextManager[Callable[[HistoryT], list[str]] | None]:
|
||||
"""Isolate one generation attempt and resolve its history model.
|
||||
|
||||
Yield None when no history model is available; otherwise yield a
|
||||
generator accepting detached history. None prompt/model inputs select
|
||||
the built-in prompt and default model.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class MessageSuggestedQuestionsService[HistoryT]:
|
||||
"""Apply the feature policy shared by every message suggested-question endpoint."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
queries: SuggestedQuestionsContextQuery[HistoryT],
|
||||
generator: SuggestedQuestionsGeneration[HistoryT],
|
||||
) -> None:
|
||||
self._queries: SuggestedQuestionsContextQuery[HistoryT] = queries
|
||||
self._generator: SuggestedQuestionsGeneration[HistoryT] = generator
|
||||
|
||||
def get_suggested_questions(
|
||||
self,
|
||||
*,
|
||||
app_id: str,
|
||||
app_owner_tenant_id: str,
|
||||
expected_app_mode: str,
|
||||
actor: MessageActor,
|
||||
invoke_from: SuggestedQuestionsInvokeFrom,
|
||||
message_id: str,
|
||||
) -> list[str]:
|
||||
context = self._queries.prepare(
|
||||
app_id=app_id,
|
||||
app_owner_tenant_id=app_owner_tenant_id,
|
||||
expected_app_mode=expected_app_mode,
|
||||
actor=actor,
|
||||
invoke_from=invoke_from,
|
||||
message_id=message_id,
|
||||
)
|
||||
if context is None:
|
||||
return []
|
||||
|
||||
enabled = context.config.get("enabled", False)
|
||||
# Agent and workflow features use truthiness; legacy model configs have
|
||||
# historically disabled this feature only for an explicit False value.
|
||||
disabled = not enabled if context.app_mode in {"agent", "advanced-chat"} else enabled is False
|
||||
if disabled:
|
||||
raise SuggestedQuestionsAfterAnswerDisabledError()
|
||||
|
||||
prompt = context.config.get("prompt")
|
||||
instruction_prompt = prompt if isinstance(prompt, str) and prompt.strip() else None
|
||||
with self._generator.prepare(
|
||||
context=context,
|
||||
instruction_prompt=instruction_prompt,
|
||||
model_config=context.config.get("model"),
|
||||
) as generate:
|
||||
if generate is None:
|
||||
return []
|
||||
history = self._queries.load_history(context=context)
|
||||
return generate(history)
|
||||
@@ -12,12 +12,10 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.console.app.message import AppMessageFeedbackPayload, ChatMessagesQuery
|
||||
from controllers.console.app.message import attach_message_extra_contents as _attach_message_extra_contents
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.enums import ConversationFromSource, ConversationStatus, FeedbackRating
|
||||
from models.model import AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
|
||||
from models.model import AppMode, AppModelConfig, Conversation, Message, MessageAnnotation, MessageFeedback
|
||||
from models.provider import Provider
|
||||
from tests.e2e_tests.containers.controllers.console.helpers import (
|
||||
authenticate_console_client,
|
||||
create_console_account_and_tenant,
|
||||
@@ -236,63 +234,65 @@ def test_message_annotation_count(
|
||||
assert response.get_json() == {"count": 1}
|
||||
|
||||
|
||||
def test_message_suggested_questions_success(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
message_id = str(uuid4())
|
||||
|
||||
with patch(
|
||||
"services.message_suggested_questions_adapters.MessageSuggestedQuestionsRuntime.get_suggested_questions",
|
||||
return_value=["q1", "q2"],
|
||||
):
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/chat-messages/{message_id}/suggested-questions",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"data": ["q1", "q2"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "expected_status", "expected_code"),
|
||||
("scenario", "expected_status", "expected_code"),
|
||||
[
|
||||
(MessageNotExistsError(), 404, "not_found"),
|
||||
(ConversationNotExistsError(), 404, "not_found"),
|
||||
(ProviderTokenNotInitError(), 400, "provider_not_initialize"),
|
||||
(QuotaExceededError(), 400, "provider_quota_exceeded"),
|
||||
(ModelCurrentlyNotSupportError(), 400, "model_currently_not_support"),
|
||||
(SuggestedQuestionsAfterAnswerDisabledError(), 403, "app_suggested_questions_after_answer_disabled"),
|
||||
(Exception(), 500, "internal_server_error"),
|
||||
("invalid-provider", 200, None),
|
||||
("missing-message", 404, "not_found"),
|
||||
("other-account", 404, "not_found"),
|
||||
("deleted-conversation", 404, "not_found"),
|
||||
("disabled", 403, "app_suggested_questions_after_answer_disabled"),
|
||||
("broken-config", 500, "internal_server_error"),
|
||||
],
|
||||
)
|
||||
def test_message_suggested_questions_errors(
|
||||
exc: Exception,
|
||||
def test_message_suggested_questions_with_persisted_configuration(
|
||||
scenario: str,
|
||||
expected_status: int,
|
||||
expected_code: str,
|
||||
expected_code: str | None,
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
message_id = str(uuid4())
|
||||
session = db_session_with_containers
|
||||
account, tenant = create_console_account_and_tenant(session)
|
||||
app = create_console_app(session, tenant.id, account.id, AppMode.CHAT)
|
||||
config = AppModelConfig(
|
||||
app_id=app.id,
|
||||
suggested_questions_after_answer=(
|
||||
"not json"
|
||||
if scenario == "broken-config"
|
||||
else '{"enabled":false}'
|
||||
if scenario == "disabled"
|
||||
else '{"enabled":true}'
|
||||
),
|
||||
)
|
||||
session.add(config)
|
||||
session.flush()
|
||||
app.app_model_config_id = config.id
|
||||
conversation = _create_conversation(session, app.id, account.id, AppMode.CHAT)
|
||||
conversation.app_model_config_id = config.id
|
||||
message = _create_message(session, app.id, conversation.id, account.id)
|
||||
if scenario == "other-account":
|
||||
message.from_account_id = str(uuid4())
|
||||
elif scenario == "deleted-conversation":
|
||||
conversation.is_deleted = True
|
||||
elif scenario == "invalid-provider":
|
||||
# Real model resolution rejects the stored ID before contacting the daemon.
|
||||
session.add(Provider(tenant_id=tenant.id, provider_name="invalid/provider", is_valid=True))
|
||||
session.commit()
|
||||
message_id = str(uuid4()) if scenario == "missing-message" else message.id
|
||||
|
||||
with patch(
|
||||
"services.message_suggested_questions_adapters.MessageSuggestedQuestionsRuntime.get_suggested_questions",
|
||||
side_effect=exc,
|
||||
):
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/chat-messages/{message_id}/suggested-questions",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/chat-messages/{message_id}/suggested-questions",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == expected_status
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["code"] == expected_code
|
||||
if expected_code is None:
|
||||
assert payload == {"data": []}
|
||||
else:
|
||||
assert payload["code"] == expected_code
|
||||
|
||||
|
||||
def test_message_api_get_success(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from unittest.mock import ANY, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
@@ -11,7 +11,6 @@ from services.errors.message import (
|
||||
FirstMessageNotExistsError,
|
||||
LastMessageNotExistsError,
|
||||
MessageNotExistsError,
|
||||
SuggestedQuestionsAfterAnswerDisabledError,
|
||||
)
|
||||
from services.message_service import MessageService
|
||||
from tests.e2e_tests.containers.helpers import generate_valid_password
|
||||
@@ -23,53 +22,8 @@ class TestMessageService:
|
||||
@pytest.fixture
|
||||
def mock_external_service_dependencies(self):
|
||||
"""Mock setup for external service dependencies."""
|
||||
with (
|
||||
patch("services.account.login_adapters.SystemFeatureService") as mock_account_feature_service,
|
||||
patch("services.message_service.ModelManager.for_tenant") as mock_model_manager,
|
||||
patch("services.message_service.WorkflowService") as mock_workflow_service,
|
||||
patch("services.message_service.AdvancedChatAppConfigManager") as mock_app_config_manager,
|
||||
patch("services.message_service.LLMGenerator") as mock_llm_generator,
|
||||
patch("services.message_service.TraceQueueManager") as mock_trace_manager_class,
|
||||
patch("services.message_service.TokenBufferMemory") as mock_token_buffer_memory,
|
||||
):
|
||||
# Setup default mock returns
|
||||
|
||||
# Mock ModelManager
|
||||
mock_model_instance = mock_model_manager.return_value.get_default_model_instance.return_value
|
||||
mock_model_instance.get_tts_voices.return_value = [{"value": "test-voice"}]
|
||||
|
||||
# Mock get_model_instance method as well
|
||||
mock_model_manager.return_value.get_model_instance.return_value = mock_model_instance
|
||||
|
||||
# Mock WorkflowService
|
||||
mock_workflow = mock_workflow_service.return_value.get_published_workflow.return_value
|
||||
mock_workflow_service.return_value.get_draft_workflow.return_value = mock_workflow
|
||||
|
||||
# Mock AdvancedChatAppConfigManager
|
||||
mock_app_config = mock_app_config_manager.get_app_config.return_value
|
||||
mock_app_config.additional_features.suggested_questions_after_answer = True
|
||||
|
||||
# Mock LLMGenerator
|
||||
mock_llm_generator.generate_suggested_questions_after_answer.return_value = ["Question 1", "Question 2"]
|
||||
|
||||
# Mock TraceQueueManager
|
||||
mock_trace_manager_instance = mock_trace_manager_class.return_value
|
||||
|
||||
# Mock TokenBufferMemory
|
||||
mock_memory_instance = mock_token_buffer_memory.return_value
|
||||
mock_memory_instance.get_history_prompt_text.return_value = "Mocked history prompt"
|
||||
|
||||
yield {
|
||||
"account_feature_service": mock_account_feature_service,
|
||||
"model_manager": mock_model_manager,
|
||||
"workflow_service": mock_workflow_service,
|
||||
"app_config_manager": mock_app_config_manager,
|
||||
"llm_generator": mock_llm_generator,
|
||||
"trace_manager_class": mock_trace_manager_class,
|
||||
"trace_manager_instance": mock_trace_manager_instance,
|
||||
"token_buffer_memory": mock_token_buffer_memory,
|
||||
# "current_user": mock_current_user,
|
||||
}
|
||||
with patch("services.account.login_adapters.SystemFeatureService") as mock_account_feature_service:
|
||||
yield {"account_feature_service": mock_account_feature_service}
|
||||
|
||||
def _create_test_app_and_account(self, db_session_with_containers: Session, mock_external_service_dependencies):
|
||||
"""
|
||||
@@ -104,7 +58,7 @@ class TestMessageService:
|
||||
app_args = CreateAppParams(
|
||||
name=fake.company(),
|
||||
description=fake.text(max_nb_chars=100),
|
||||
mode="advanced-chat", # Use advanced-chat mode to use mocked workflow,
|
||||
mode="advanced-chat",
|
||||
icon_type="emoji",
|
||||
icon="🤖",
|
||||
icon_background="#FF6B6B",
|
||||
@@ -715,170 +669,3 @@ class TestMessageService:
|
||||
MessageService.get_message(
|
||||
app_model=app, user=other_account, message_id=message.id, session=db_session_with_containers
|
||||
)
|
||||
|
||||
def test_get_suggested_questions_after_answer_success(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test successful generation of suggested questions after answer.
|
||||
"""
|
||||
fake = Faker()
|
||||
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
|
||||
|
||||
# Create a conversation and message
|
||||
conversation = self._create_test_conversation(db_session_with_containers, app, account, fake)
|
||||
message = self._create_test_message(db_session_with_containers, app, conversation, account, fake)
|
||||
|
||||
# Mock the LLMGenerator to return specific questions
|
||||
mock_questions = ["What is AI?", "How does machine learning work?", "Tell me about neural networks"]
|
||||
mock_external_service_dependencies[
|
||||
"llm_generator"
|
||||
].generate_suggested_questions_after_answer.return_value = mock_questions
|
||||
|
||||
# Get suggested questions
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app,
|
||||
user=account,
|
||||
message_id=message.id,
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=db_session_with_containers,
|
||||
)
|
||||
|
||||
# Verify results
|
||||
assert result == mock_questions
|
||||
|
||||
# Verify LLMGenerator was called
|
||||
mock_external_service_dependencies[
|
||||
"llm_generator"
|
||||
].generate_suggested_questions_after_answer.assert_called_once()
|
||||
|
||||
# Verify TraceQueueManager was called
|
||||
mock_external_service_dependencies["trace_manager_instance"].add_trace_task.assert_called_once()
|
||||
|
||||
def test_get_suggested_questions_after_answer_no_user(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test getting suggested questions when no user is provided.
|
||||
"""
|
||||
fake = Faker()
|
||||
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
|
||||
|
||||
# Create a conversation and message
|
||||
conversation = self._create_test_conversation(db_session_with_containers, app, account, fake)
|
||||
message = self._create_test_message(db_session_with_containers, app, conversation, account, fake)
|
||||
|
||||
# Test getting suggested questions with no user
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
|
||||
with pytest.raises(ValueError, match="user cannot be None"):
|
||||
MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app,
|
||||
user=None,
|
||||
message_id=message.id,
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=db_session_with_containers,
|
||||
)
|
||||
|
||||
def test_get_suggested_questions_after_answer_disabled(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test getting suggested questions when feature is disabled.
|
||||
"""
|
||||
fake = Faker()
|
||||
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
|
||||
|
||||
# Create a conversation and message
|
||||
conversation = self._create_test_conversation(db_session_with_containers, app, account, fake)
|
||||
message = self._create_test_message(db_session_with_containers, app, conversation, account, fake)
|
||||
|
||||
# Mock the feature to be disabled
|
||||
mock_external_service_dependencies[
|
||||
"app_config_manager"
|
||||
].get_app_config.return_value.additional_features.suggested_questions_after_answer = False
|
||||
|
||||
# Test getting suggested questions when feature is disabled
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
|
||||
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
|
||||
MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app,
|
||||
user=account,
|
||||
message_id=message.id,
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=db_session_with_containers,
|
||||
)
|
||||
|
||||
def test_get_suggested_questions_after_answer_no_workflow(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test getting suggested questions when no workflow exists.
|
||||
"""
|
||||
fake = Faker()
|
||||
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
|
||||
|
||||
# Create a conversation and message
|
||||
conversation = self._create_test_conversation(db_session_with_containers, app, account, fake)
|
||||
message = self._create_test_message(db_session_with_containers, app, conversation, account, fake)
|
||||
|
||||
# Mock no workflow
|
||||
mock_external_service_dependencies["workflow_service"].return_value.get_published_workflow.return_value = None
|
||||
|
||||
# Get suggested questions (should return empty list)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app,
|
||||
user=account,
|
||||
message_id=message.id,
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=db_session_with_containers,
|
||||
)
|
||||
|
||||
# Verify empty result
|
||||
assert result == []
|
||||
|
||||
def test_get_suggested_questions_after_answer_debugger_mode(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test getting suggested questions in debugger mode.
|
||||
"""
|
||||
fake = Faker()
|
||||
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
|
||||
|
||||
# Create a conversation and message
|
||||
conversation = self._create_test_conversation(db_session_with_containers, app, account, fake)
|
||||
message = self._create_test_message(db_session_with_containers, app, conversation, account, fake)
|
||||
|
||||
# Mock questions
|
||||
mock_questions = ["Debug question 1", "Debug question 2"]
|
||||
mock_external_service_dependencies[
|
||||
"llm_generator"
|
||||
].generate_suggested_questions_after_answer.return_value = mock_questions
|
||||
|
||||
# Get suggested questions in debugger mode
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app,
|
||||
user=account,
|
||||
message_id=message.id,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
session=db_session_with_containers,
|
||||
)
|
||||
|
||||
# Verify results
|
||||
assert result == mock_questions
|
||||
|
||||
# Verify draft workflow was used instead of published workflow
|
||||
mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.assert_called_once_with(
|
||||
app_model=app, session=ANY
|
||||
)
|
||||
|
||||
# Verify TraceQueueManager was called
|
||||
mock_external_service_dependencies["trace_manager_instance"].add_trace_task.assert_called_once()
|
||||
@@ -0,0 +1,456 @@
|
||||
"""Exercise real model resolution, generation and failures with PostgreSQL/Redis.
|
||||
|
||||
Only provider discovery, decoded credentials and schema data are seeded in their
|
||||
normal caches. ModelManager and HTTP transport run normally, against either a
|
||||
loopback provider returning valid SSE or a daemon with a missing installation.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from http import HTTPStatus
|
||||
from threading import Thread
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from flask import Flask, Response
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy import Connection, delete, event, select, update
|
||||
from sqlalchemy.orm import Session, SessionTransaction
|
||||
from werkzeug.serving import make_server
|
||||
from yarl import URL
|
||||
|
||||
from configs import dify_config
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
|
||||
from core.model_context import get_credit_usage_metadata
|
||||
from core.model_manager import ModelManager
|
||||
from core.ops import ops_trace_manager
|
||||
from core.plugin.entities.plugin_daemon import PluginModelProviderDeclaration
|
||||
from core.plugin.impl import base as plugin_base
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod
|
||||
from models.account import Account
|
||||
from models.enums import ConversationFromSource
|
||||
from models.model import App, AppMode, AppModelConfig, Conversation, Message
|
||||
from models.provider import LoadBalancingModelConfig, Provider, ProviderModelSetting, TenantDefaultModel
|
||||
from services.entities.message_entities import MessageAccount
|
||||
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
|
||||
from services.message_suggested_questions_service import SuggestedQuestionsContext
|
||||
from tests.e2e_tests.containers.controllers.console.helpers import (
|
||||
authenticate_console_client,
|
||||
create_console_account_and_tenant,
|
||||
create_console_app,
|
||||
)
|
||||
|
||||
_PROVIDER = "tests/suggested/suggested"
|
||||
_MODEL = "questions-model"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Scenario:
|
||||
account: Account
|
||||
context: SuggestedQuestionsContext
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scenario(db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch) -> Iterator[_Scenario]:
|
||||
monkeypatch.setattr(dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED", True)
|
||||
session = db_session_with_containers
|
||||
account, tenant = create_console_account_and_tenant(session)
|
||||
app = create_console_app(session, tenant.id, account.id, AppMode.CHAT)
|
||||
config = AppModelConfig(app_id=app.id, suggested_questions_after_answer='{"enabled":true}')
|
||||
session.add(config)
|
||||
session.flush()
|
||||
app.app_model_config_id = config.id
|
||||
conversation = Conversation(
|
||||
app_id=app.id,
|
||||
app_model_config_id=config.id,
|
||||
mode=AppMode.CHAT,
|
||||
name="Suggested questions",
|
||||
_inputs={},
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
)
|
||||
session.add(conversation)
|
||||
session.flush()
|
||||
message = Message(
|
||||
app_id=app.id,
|
||||
conversation_id=conversation.id,
|
||||
_inputs={},
|
||||
query="How does this work?",
|
||||
message={},
|
||||
answer="Read the documentation.",
|
||||
message_unit_price=Decimal(0),
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
)
|
||||
provider = Provider(tenant_id=tenant.id, provider_name=_PROVIDER, is_valid=True)
|
||||
session.add_all([message, provider])
|
||||
session.flush()
|
||||
context = SuggestedQuestionsContext(
|
||||
app_id=app.id,
|
||||
tenant_id=tenant.id,
|
||||
app_mode=AppMode.CHAT,
|
||||
message_id=message.id,
|
||||
conversation_id=conversation.id,
|
||||
actor=MessageAccount(account.id),
|
||||
invoke_from="debugger",
|
||||
config={"enabled": True},
|
||||
)
|
||||
session.add(
|
||||
TenantDefaultModel(tenant_id=tenant.id, provider_name=_PROVIDER, model_name=_MODEL, model_type=ModelType.LLM)
|
||||
)
|
||||
schema = AIModelEntity(
|
||||
model=_MODEL,
|
||||
label=I18nObject(en_US="Questions"),
|
||||
model_type=ModelType.LLM,
|
||||
fetch_from=FetchFrom.PREDEFINED_MODEL,
|
||||
model_properties={},
|
||||
parameter_rules=[],
|
||||
)
|
||||
declaration = PluginModelProviderDeclaration(
|
||||
provider=_PROVIDER,
|
||||
plugin_unique_identifier="tests/suggested:0.0.1@" + "0" * 64,
|
||||
installation_source=None,
|
||||
label=I18nObject(en_US="Suggested questions"),
|
||||
supported_model_types=[ModelType.LLM],
|
||||
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
||||
models=[schema],
|
||||
)
|
||||
credentials = {"api_key": "unused-test-key"}
|
||||
PluginService._store_cached_plugin_model_providers(tenant.id, 0, [declaration])
|
||||
ProviderCredentialsCache(tenant.id, provider.id, ProviderCredentialsCacheType.PROVIDER).set(credentials)
|
||||
redis_client.setex(f"tenant:{tenant.id}:model_load_balancing_enabled", 60, "False")
|
||||
runtime = create_plugin_model_runtime(tenant_id=tenant.id)
|
||||
runtime.bind_provider_schema(declaration)
|
||||
schema_key = runtime._get_schema_cache_key(
|
||||
provider=_PROVIDER, model_type=ModelType.LLM, model=_MODEL, credentials=credentials
|
||||
)
|
||||
redis_client.setex(schema_key, 60, schema.model_dump_json())
|
||||
session.commit()
|
||||
|
||||
# The container fixture reloads config after this module-level URL was bound.
|
||||
monkeypatch.setattr(plugin_base, "plugin_daemon_inner_api_baseurl", URL(str(dify_config.PLUGIN_DAEMON_URL)))
|
||||
previous_timer = ops_trace_manager.trace_manager_timer
|
||||
try:
|
||||
yield _Scenario(account=account, context=context)
|
||||
finally:
|
||||
timer = ops_trace_manager.trace_manager_timer
|
||||
if timer is not None and timer is not previous_timer:
|
||||
timer.cancel()
|
||||
timer.join(timeout=1)
|
||||
ops_trace_manager.trace_manager_timer = previous_timer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _successful_model_provider(scenario: _Scenario, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
app = Flask(__name__)
|
||||
route = f"/plugin/{scenario.context.tenant_id}/dispatch/llm"
|
||||
|
||||
@app.post(f"{route}/num_tokens")
|
||||
def count_tokens() -> Response:
|
||||
payload = {"code": 0, "message": "success", "data": {"num_tokens": 8}}
|
||||
return Response(f"data: {json.dumps(payload)}\n\n", mimetype="text/event-stream")
|
||||
|
||||
@app.post(f"{route}/invoke")
|
||||
def invoke() -> Response:
|
||||
def chunks() -> Iterator[str]:
|
||||
for index, content in enumerate(
|
||||
('```json\n["What should I try next?",', '"Can you show an example?"]\n```')
|
||||
):
|
||||
payload = {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"model": _MODEL,
|
||||
"delta": {
|
||||
"index": index,
|
||||
"message": {"role": "assistant", "content": content},
|
||||
"finish_reason": "stop" if index == 1 else None,
|
||||
},
|
||||
},
|
||||
}
|
||||
yield f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
return Response(chunks(), mimetype="text/event-stream")
|
||||
|
||||
server = make_server("127.0.0.1", 0, app, threaded=True)
|
||||
thread = Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
|
||||
thread.start()
|
||||
monkeypatch.setattr(plugin_base, "plugin_daemon_inner_api_baseurl", URL(f"http://127.0.0.1:{server.server_port}"))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
server.server_close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured_model", [False, True], ids=["default-model", "configured-model"])
|
||||
@pytest.mark.usefixtures("_successful_model_provider")
|
||||
def test_console_returns_generated_questions_through_real_http_provider(
|
||||
scenario: _Scenario,
|
||||
test_client_with_containers: FlaskClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
configured_model: bool,
|
||||
) -> None:
|
||||
feature: dict[str, object] = {"enabled": True, "prompt": "Give two concrete follow-ups."}
|
||||
if configured_model:
|
||||
feature["model"] = {"provider": _PROVIDER, "name": _MODEL, "completion_params": {"temperature": 0.2}}
|
||||
db.session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.app_id == scenario.context.app_id)
|
||||
.values(suggested_questions_after_answer=json.dumps(feature))
|
||||
)
|
||||
db.session.commit()
|
||||
headers = authenticate_console_client(test_client_with_containers, scenario.account)
|
||||
monkeypatch.setattr(dify_config, "PLUGIN_BASED_TOKEN_COUNTING_ENABLED", True)
|
||||
db.session.remove()
|
||||
caller = db.session()
|
||||
sessions: list[Session] = []
|
||||
requests: list[httpx.Request] = []
|
||||
active_sessions: list[Session] = []
|
||||
|
||||
def remember_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
sessions.append(session)
|
||||
|
||||
def observe_request(request: httpx.Request) -> None:
|
||||
requests.append(request)
|
||||
active_sessions.extend(session for session in sessions if session is not caller and session.in_transaction())
|
||||
|
||||
event.listen(Session, "after_begin", remember_session)
|
||||
hooks = plugin_base._httpx_client.event_hooks["request"]
|
||||
hooks.append(observe_request)
|
||||
try:
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{scenario.context.app_id}/chat-messages/"
|
||||
f"{scenario.context.message_id}/suggested-questions",
|
||||
headers=headers,
|
||||
)
|
||||
finally:
|
||||
hooks.remove(observe_request)
|
||||
event.remove(Session, "after_begin", remember_session)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
assert response.get_json() == {"data": ["What should I try next?", "Can you show an example?"]}
|
||||
assert [request.url.path for request in requests] == [
|
||||
f"/plugin/{scenario.context.tenant_id}/dispatch/llm/num_tokens",
|
||||
f"/plugin/{scenario.context.tenant_id}/dispatch/llm/invoke",
|
||||
]
|
||||
assert not active_sessions
|
||||
invocation = requests[1]
|
||||
payload = json.loads(invocation.content)["data"]
|
||||
assert payload["provider"] == "suggested"
|
||||
assert payload["model"] == _MODEL
|
||||
assert payload["credentials"] == {"api_key": "unused-test-key"}
|
||||
assert payload["stream"] is False
|
||||
assert payload["model_parameters"] == (
|
||||
{"temperature": 0.2} if configured_model else {"max_tokens": 256, "temperature": 0.0}
|
||||
)
|
||||
assert len(payload["prompt_messages"]) == 1
|
||||
prompt = payload["prompt_messages"][0]
|
||||
assert prompt["role"] == "user"
|
||||
assert "Human: How does this work?\nAssistant: Read the documentation." in prompt["content"]
|
||||
assert "Give two concrete follow-ups." in prompt["content"]
|
||||
assert 'You must output a JSON array like ["question1", "question2", "question3"].' in prompt["content"]
|
||||
assert invocation.headers["X-Plugin-ID"] == "tests/suggested"
|
||||
assert invocation.headers["X-Plugin-Unique-Identifier"] == "tests/suggested:0.0.1@" + "0" * 64
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured_provider", [_PROVIDER, "tests/missing/missing"])
|
||||
def test_configured_model_resolution_and_default_fallback(scenario: _Scenario, configured_provider: str) -> None:
|
||||
prepared = SuggestedQuestionsGenerator._prepare_model(
|
||||
tenant_id=scenario.context.tenant_id,
|
||||
model_config={"provider": configured_provider, "name": _MODEL, "completion_params": {"temperature": 0.2}},
|
||||
)
|
||||
|
||||
assert prepared is not None
|
||||
assert prepared.model_instance.provider == _PROVIDER
|
||||
assert prepared.model_instance.model_name == _MODEL
|
||||
assert prepared.model_instance.credentials == {"api_key": "unused-test-key"}
|
||||
assert prepared.completion_params == ({"temperature": 0.2} if configured_provider == _PROVIDER else None)
|
||||
|
||||
|
||||
def test_unavailable_configured_and_default_models_return_none(scenario: _Scenario) -> None:
|
||||
db.session.execute(
|
||||
update(TenantDefaultModel)
|
||||
.where(TenantDefaultModel.tenant_id == scenario.context.tenant_id)
|
||||
.values(provider_name="tests/missing/missing")
|
||||
)
|
||||
db.session.commit()
|
||||
generator = SuggestedQuestionsGenerator()
|
||||
|
||||
assert (
|
||||
generator._prepare_model(
|
||||
tenant_id=scenario.context.tenant_id, model_config={"provider": "tests/missing/missing", "name": _MODEL}
|
||||
)
|
||||
is None
|
||||
)
|
||||
with generator.prepare(context=scenario.context, instruction_prompt=None, model_config=None) as generate:
|
||||
assert generate is None
|
||||
|
||||
|
||||
def test_missing_default_model_does_not_create_one_or_commit_caller_changes(scenario: _Scenario) -> None:
|
||||
db.session.execute(delete(TenantDefaultModel).where(TenantDefaultModel.tenant_id == scenario.context.tenant_id))
|
||||
db.session.commit()
|
||||
app = db.session.get(App, scenario.context.app_id)
|
||||
assert app is not None
|
||||
original_name = app.name
|
||||
app.name = "Uncommitted caller change"
|
||||
caller = db.session()
|
||||
|
||||
try:
|
||||
with SuggestedQuestionsGenerator().prepare(
|
||||
context=scenario.context, instruction_prompt=None, model_config=None
|
||||
) as generate:
|
||||
assert generate is None
|
||||
assert db.session() is not caller
|
||||
assert not db.session().in_transaction()
|
||||
with Session(db.engine) as verification:
|
||||
default = verification.scalar(
|
||||
select(TenantDefaultModel).where(TenantDefaultModel.tenant_id == scenario.context.tenant_id)
|
||||
)
|
||||
assert default is None
|
||||
assert verification.scalar(select(App.name).where(App.id == app.id)) == original_name
|
||||
assert db.session() is caller
|
||||
assert app in caller.dirty
|
||||
assert app.name == "Uncommitted caller change"
|
||||
finally:
|
||||
caller.rollback()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token_counting", [True, False])
|
||||
def test_console_missing_plugin_token_error_propagates_but_invocation_returns_empty(
|
||||
scenario: _Scenario,
|
||||
test_client_with_containers: FlaskClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
token_counting: bool,
|
||||
) -> None:
|
||||
headers = authenticate_console_client(test_client_with_containers, scenario.account)
|
||||
monkeypatch.setattr(dify_config, "PLUGIN_BASED_TOKEN_COUNTING_ENABLED", token_counting)
|
||||
db.session.remove()
|
||||
caller = db.session()
|
||||
sessions: list[Session] = []
|
||||
requests: list[httpx.Request] = []
|
||||
active_sessions: list[Session] = []
|
||||
metadata: list[dict[str, object]] = []
|
||||
|
||||
def remember_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
sessions.append(session)
|
||||
|
||||
def observe_request(request: httpx.Request) -> None:
|
||||
requests.append(request)
|
||||
active_sessions.extend(session for session in sessions if session is not caller and session.in_transaction())
|
||||
metadata.append(dict(get_credit_usage_metadata() or {}))
|
||||
|
||||
event.listen(Session, "after_begin", remember_session)
|
||||
hooks = plugin_base._httpx_client.event_hooks["request"]
|
||||
hooks.append(observe_request)
|
||||
try:
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{scenario.context.app_id}/chat-messages/"
|
||||
f"{scenario.context.message_id}/suggested-questions",
|
||||
headers=headers,
|
||||
)
|
||||
finally:
|
||||
hooks.remove(observe_request)
|
||||
event.remove(Session, "after_begin", remember_session)
|
||||
|
||||
assert len(requests) == 1
|
||||
suffix = "num_tokens" if token_counting else "invoke"
|
||||
assert requests[0].url.path == f"/plugin/{scenario.context.tenant_id}/dispatch/llm/{suffix}"
|
||||
assert not active_sessions
|
||||
errors = [record.exc_info[1] for record in caplog.records if record.exc_info and record.exc_info[1] is not None]
|
||||
# The daemon's -404 JSON response is translated to ValueError by the
|
||||
# streaming transport; only -500 responses deserialize a plugin exception.
|
||||
assert any(
|
||||
isinstance(error, ValueError) and "plugin not found" in str(error) and "code: -404" in str(error)
|
||||
for error in errors
|
||||
)
|
||||
payload = response.get_json()
|
||||
assert isinstance(payload, dict)
|
||||
if token_counting:
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
assert payload["code"] == "internal_server_error"
|
||||
else:
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert payload == {"data": []}
|
||||
assert requests[0].extensions["timeout"] == {"connect": 30.0, "read": 30.0, "write": 30.0, "pool": 30.0}
|
||||
assert metadata == [{"app_type": "chatbot", "created_by": "suggested_questions"}]
|
||||
|
||||
|
||||
def test_console_token_counting_reports_credentials_in_cooldown(
|
||||
scenario: _Scenario,
|
||||
test_client_with_containers: FlaskClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
tenant_id = scenario.context.tenant_id
|
||||
balancing = LoadBalancingModelConfig(
|
||||
tenant_id=tenant_id, provider_name=_PROVIDER, model_name=_MODEL, model_type=ModelType.LLM, name="__inherit__"
|
||||
)
|
||||
backup = LoadBalancingModelConfig(
|
||||
tenant_id=tenant_id,
|
||||
provider_name=_PROVIDER,
|
||||
model_name=_MODEL,
|
||||
model_type=ModelType.LLM,
|
||||
name="Backup credentials",
|
||||
encrypted_config='{"api_key":"unused-test-key"}',
|
||||
)
|
||||
db.session.add_all(
|
||||
[
|
||||
balancing,
|
||||
backup,
|
||||
ProviderModelSetting(
|
||||
tenant_id=tenant_id,
|
||||
provider_name=_PROVIDER,
|
||||
model_name=_MODEL,
|
||||
model_type=ModelType.LLM,
|
||||
load_balancing_enabled=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.session.commit()
|
||||
ProviderCredentialsCache(tenant_id, backup.id, ProviderCredentialsCacheType.LOAD_BALANCING_MODEL).set(
|
||||
{"api_key": "unused-test-key"}
|
||||
)
|
||||
redis_client.setex(f"tenant:{tenant_id}:model_load_balancing_enabled", 60, "True")
|
||||
model = ModelManager.for_tenant(tenant_id=tenant_id).get_default_model_instance(
|
||||
tenant_id=tenant_id, model_type=ModelType.LLM
|
||||
)
|
||||
manager = model.load_balancing_manager
|
||||
assert manager is not None
|
||||
assert {configuration.id for configuration in manager._load_balancing_configs} == {balancing.id, backup.id}
|
||||
for configuration in manager._load_balancing_configs:
|
||||
manager.cooldown(configuration)
|
||||
assert manager.in_cooldown(configuration)
|
||||
monkeypatch.setattr(dify_config, "PLUGIN_BASED_TOKEN_COUNTING_ENABLED", True)
|
||||
headers = authenticate_console_client(test_client_with_containers, scenario.account)
|
||||
db.session.remove()
|
||||
requests: list[httpx.Request] = []
|
||||
hooks = plugin_base._httpx_client.event_hooks["request"]
|
||||
hooks.append(requests.append)
|
||||
try:
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{scenario.context.app_id}/chat-messages/"
|
||||
f"{scenario.context.message_id}/suggested-questions",
|
||||
headers=headers,
|
||||
)
|
||||
finally:
|
||||
hooks.remove(requests.append)
|
||||
|
||||
assert requests == []
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
payload = response.get_json()
|
||||
assert isinstance(payload, dict)
|
||||
assert payload["code"] == "provider_not_initialize"
|
||||
assert payload["message"] == "Model credentials is not initialized."
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
"""Suggested-question tuning using real model objects and the Redis schema cache."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from core.entities.provider_configuration import ProviderConfiguration, ProviderModelBundle
|
||||
from core.entities.provider_entities import CustomConfiguration, SystemConfiguration
|
||||
from core.model_manager import ModelInstance
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import (
|
||||
AIModelEntity,
|
||||
FetchFrom,
|
||||
ModelType,
|
||||
ParameterRule,
|
||||
ParameterType,
|
||||
)
|
||||
from graphon.model_runtime.entities.provider_entities import ProviderEntity
|
||||
from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel
|
||||
from models.provider import ProviderType
|
||||
from services.message_suggested_questions_generator import _default_suggested_questions_model_parameters
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("thinking_type", "thinking_options", "reasoning_options", "expected"),
|
||||
[
|
||||
(None, [], [], {}),
|
||||
(None, [], ["minimal", "low", "medium", "high"], {"reasoning_effort": "minimal"}),
|
||||
(None, [], ["low", "none", "minimal"], {"reasoning_effort": "none"}),
|
||||
(None, [], ["low", "high"], {"reasoning_effort": "low"}),
|
||||
(None, [], ["medium", "high"], {}),
|
||||
(ParameterType.BOOLEAN, [], ["low", "high"], {"thinking": False}),
|
||||
(ParameterType.STRING, ["enabled", "disabled"], ["low", "high"], {"thinking": "disabled"}),
|
||||
(ParameterType.STRING, ["enabled"], ["low", "high"], {"reasoning_effort": "low"}),
|
||||
],
|
||||
)
|
||||
def test_default_model_parameters_use_cached_provider_schema(
|
||||
flask_app_with_containers: Flask,
|
||||
thinking_type: ParameterType | None,
|
||||
thinking_options: list[str],
|
||||
reasoning_options: list[str],
|
||||
expected: dict[str, object],
|
||||
) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
provider = ProviderEntity(
|
||||
provider="langgenius/openai/openai",
|
||||
label=I18nObject(en_US="OpenAI"),
|
||||
supported_model_types=[ModelType.LLM],
|
||||
configurate_methods=[],
|
||||
)
|
||||
rules = []
|
||||
if thinking_type is not None:
|
||||
rules.append(
|
||||
ParameterRule(
|
||||
name="thinking", label=I18nObject(en_US="Thinking"), type=thinking_type, options=thinking_options
|
||||
)
|
||||
)
|
||||
if reasoning_options:
|
||||
rules.append(
|
||||
ParameterRule(
|
||||
name="reasoning_effort",
|
||||
label=I18nObject(en_US="Reasoning effort"),
|
||||
type=ParameterType.STRING,
|
||||
options=reasoning_options,
|
||||
)
|
||||
)
|
||||
schema = AIModelEntity(
|
||||
model="questions-model",
|
||||
label=I18nObject(en_US="Questions model"),
|
||||
model_type=ModelType.LLM,
|
||||
fetch_from=FetchFrom.PREDEFINED_MODEL,
|
||||
model_properties={},
|
||||
parameter_rules=rules,
|
||||
)
|
||||
runtime = create_plugin_model_runtime(tenant_id=tenant_id)
|
||||
model = ModelInstance(
|
||||
provider_model_bundle=ProviderModelBundle(
|
||||
configuration=ProviderConfiguration(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
preferred_provider_type=ProviderType.CUSTOM,
|
||||
using_provider_type=ProviderType.CUSTOM,
|
||||
system_configuration=SystemConfiguration(enabled=False),
|
||||
custom_configuration=CustomConfiguration(provider=None),
|
||||
model_settings=[],
|
||||
),
|
||||
model_type_instance=LargeLanguageModel(provider_schema=provider, model_runtime=runtime),
|
||||
),
|
||||
model=schema.model,
|
||||
credentials={},
|
||||
)
|
||||
cache_key = runtime._get_schema_cache_key(
|
||||
provider=provider.provider, model_type=ModelType.LLM, model=schema.model, credentials={}
|
||||
)
|
||||
with flask_app_with_containers.app_context():
|
||||
redis_client.setex(cache_key, 60, schema.model_dump_json())
|
||||
try:
|
||||
assert model.get_model_schema() == schema
|
||||
assert _default_suggested_questions_model_parameters(model) == {
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.0,
|
||||
**expected,
|
||||
}
|
||||
finally:
|
||||
redis_client.delete(cache_key)
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
"""Console suggested-question admission and actor/configuration isolation over SQLite."""
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from http import HTTPStatus
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Connection, Engine, delete, event, func, select, text, update
|
||||
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
||||
from werkzeug.test import TestResponse
|
||||
|
||||
import controllers.console.wraps as console_wraps
|
||||
from controllers.console.app import message as controller
|
||||
from enums import DeploymentEdition
|
||||
from extensions.application_services.app import AppServices, build_app_services
|
||||
from extensions.application_services.rbac import RBACServices
|
||||
from extensions.ext_application_services import _build_oauth_server_service
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from libs import login
|
||||
from libs.external_api import ExternalApi
|
||||
from models.account import Account, Tenant
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigVersionKind,
|
||||
AgentDebugConversation,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
AgentWorkspaceBinding,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import ConversationFromSource
|
||||
from models.model import App, AppMode, AppModelConfig, Conversation, DifySetup, Message
|
||||
from models.provider import Provider, TenantDefaultModel
|
||||
from repositories.app.agent_app_repository import AgentAppRepository
|
||||
from repositories.app.console_repository import ConsoleAppRepository
|
||||
from repositories.app.tracing_config_repository import SQLAlchemyAppTracingConfigRepository
|
||||
from repositories.message_repository import MessageRepository
|
||||
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
|
||||
from services.agent.roster_package_exporter import RosterAgentPackageExporter
|
||||
from services.app.agent_app_service import AgentAppAccessService
|
||||
from services.app_tracing_config_gateway import OpsTraceManagerGateway
|
||||
from services.app_tracing_config_service import AppTracingConfigService
|
||||
from services.entities.message_entities import MessageAccount
|
||||
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
|
||||
from services.message_suggested_questions_queries import SuggestedQuestionsQuery
|
||||
from services.message_suggested_questions_service import (
|
||||
MessageSuggestedQuestions,
|
||||
MessageSuggestedQuestionsService,
|
||||
)
|
||||
from services.recommended_app_package_service import RecommendedAppPackageService
|
||||
from tests.helpers.unit.config_override import apply_config_overrides
|
||||
from tests.helpers.unit.model_factories import make_account, make_app, make_conversation, make_message
|
||||
from tests.helpers.unit.rbac_fakes import RBACDomain
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Agents:
|
||||
access: AgentAppAccessService
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Services:
|
||||
apps: AppServices
|
||||
agent_apps: _Agents
|
||||
message_suggested_questions: MessageSuggestedQuestions
|
||||
rbac: RBACServices
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Harness:
|
||||
flask_app: Flask
|
||||
target: App
|
||||
account: Account
|
||||
conversation: Conversation
|
||||
message: Message
|
||||
agent_id: str
|
||||
factory: sessionmaker[Session]
|
||||
services: _Services
|
||||
sessions: list[Session]
|
||||
queries: SuggestedQuestionsQuery
|
||||
|
||||
def get(self, route: str = "app") -> TestResponse:
|
||||
resource = f"apps/{self.target.id}" if route == "app" else f"agent/{self.agent_id}"
|
||||
return self.flask_app.test_client().get(f"/{resource}/chat-messages/{self.message.id}/suggested-questions")
|
||||
|
||||
def add_agent(self, *, scope: AgentScope = AgentScope.ROSTER, backing: bool = True) -> None:
|
||||
with self.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == self.target.id).values(mode=AppMode.AGENT))
|
||||
session.add(
|
||||
Agent(
|
||||
id=self.agent_id,
|
||||
tenant_id=self.target.tenant_id,
|
||||
name="Agent",
|
||||
scope=scope,
|
||||
source=AgentSource.AGENT_APP if scope == AgentScope.ROSTER else AgentSource.WORKFLOW,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id=self.target.id if scope == AgentScope.ROSTER else str(uuid4()),
|
||||
backing_app_id=self.target.id if scope == AgentScope.WORKFLOW_ONLY and backing else None,
|
||||
workflow_id=str(uuid4()) if scope == AgentScope.WORKFLOW_ONLY else None,
|
||||
workflow_node_id="node" if scope == AgentScope.WORKFLOW_ONLY else None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(
|
||||
_empty_model_provider_daemon: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
rbac_domain: RBACDomain,
|
||||
) -> Iterator[_Harness]:
|
||||
target = make_app(app_id=str(uuid4()), tenant_id=str(uuid4()))
|
||||
account = make_account(account_id=str(uuid4()))
|
||||
tenant = Tenant(name="Workspace")
|
||||
tenant.id = target.tenant_id
|
||||
account._current_tenant = tenant
|
||||
conversation = make_conversation(
|
||||
conversation_id=str(uuid4()),
|
||||
app_id=target.id,
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
inputs={},
|
||||
)
|
||||
config = AppModelConfig(
|
||||
app_id=target.id, suggested_questions_after_answer=json.dumps({"enabled": True, "prompt": "Console questions"})
|
||||
)
|
||||
message = make_message(
|
||||
message_id=str(uuid4()),
|
||||
app_id=target.id,
|
||||
conversation_id=conversation.id,
|
||||
inputs={},
|
||||
message={},
|
||||
query="How?",
|
||||
answer="Like this.",
|
||||
message_unit_price=Decimal(0),
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
)
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add_all(
|
||||
[
|
||||
target,
|
||||
account,
|
||||
conversation,
|
||||
config,
|
||||
message,
|
||||
DifySetup(version="test"),
|
||||
Provider(tenant_id=target.tenant_id, provider_name="invalid/provider", is_valid=True),
|
||||
TenantDefaultModel(
|
||||
tenant_id=target.tenant_id,
|
||||
provider_name="invalid/provider",
|
||||
model_name="invalid-model",
|
||||
model_type=ModelType.LLM,
|
||||
),
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
target.app_model_config_id = config.id
|
||||
conversation.app_model_config_id = config.id
|
||||
sessions: list[Session] = []
|
||||
|
||||
def track_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
sessions.append(session)
|
||||
|
||||
event.listen(sqlite_session_factory, "after_begin", track_session)
|
||||
monkeypatch.setattr(login, "current_user", account)
|
||||
console_wraps._is_setup_completed.reset_success()
|
||||
apply_config_overrides(
|
||||
monkeypatch, LOGIN_DISABLED=True, RBAC_ENABLED=False, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY
|
||||
)
|
||||
redis = RedisClientWrapper()
|
||||
app_services = build_app_services(
|
||||
database_client=sqlite_session_factory,
|
||||
repository=ConsoleAppRepository(session_factory=sqlite_session_factory),
|
||||
rbac_members=rbac_domain.rbac.members,
|
||||
tracing_configs=AppTracingConfigService(
|
||||
configs=SQLAlchemyAppTracingConfigRepository(session_factory=sqlite_session_factory),
|
||||
provider=OpsTraceManagerGateway(),
|
||||
),
|
||||
oauth=_build_oauth_server_service(database_client=sqlite_session_factory, redis=redis),
|
||||
recommended_packages=RecommendedAppPackageService(
|
||||
sources=DatabaseRecommendedAppCatalogRepository(sqlite_session_factory, redis=redis),
|
||||
exporter=RosterAgentPackageExporter(),
|
||||
),
|
||||
)
|
||||
flask_app = Flask(__name__)
|
||||
flask_app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
|
||||
db.init_app(flask_app)
|
||||
queries = SuggestedQuestionsQuery(session_factory=sqlite_session_factory, repository_factory=MessageRepository)
|
||||
services = _Services(
|
||||
app_services,
|
||||
_Agents(AgentAppAccessService(references=AgentAppRepository(session_factory=sqlite_session_factory))),
|
||||
MessageSuggestedQuestionsService(queries=queries, generator=SuggestedQuestionsGenerator()),
|
||||
rbac_domain.rbac,
|
||||
)
|
||||
flask_app.extensions["application_services"] = services
|
||||
api = ExternalApi(flask_app)
|
||||
api.add_resource(
|
||||
controller.MessageSuggestedQuestionApi,
|
||||
"/apps/<uuid:app_id>/chat-messages/<uuid:message_id>/suggested-questions",
|
||||
)
|
||||
api.add_resource(
|
||||
controller.AgentMessageSuggestedQuestionApi,
|
||||
"/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions",
|
||||
)
|
||||
result = _Harness(
|
||||
flask_app,
|
||||
target,
|
||||
account,
|
||||
conversation,
|
||||
message,
|
||||
str(uuid4()),
|
||||
sqlite_session_factory,
|
||||
services,
|
||||
sessions,
|
||||
queries,
|
||||
)
|
||||
|
||||
yield result
|
||||
event.remove(sqlite_session_factory, "after_begin", track_session)
|
||||
console_wraps._is_setup_completed.reset_success()
|
||||
with flask_app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT])
|
||||
def test_model_resolution_failure_keeps_empty_http_success(
|
||||
harness: _Harness, mode: AppMode, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
|
||||
response = harness.get()
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
assert response.get_json() == {"data": []}
|
||||
assert any(
|
||||
record.exc_info
|
||||
and isinstance(record.exc_info[1], ValueError)
|
||||
and "Invalid provider: invalid/provider" in str(record.exc_info[1])
|
||||
for record in caplog.records
|
||||
)
|
||||
assert all(not session.in_transaction() and not session.identity_map for session in harness.sessions)
|
||||
|
||||
|
||||
def test_advanced_chat_without_draft_workflow_returns_empty_success(harness: _Harness) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.ADVANCED_CHAT))
|
||||
response = harness.get()
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.get_json() == {"data": []}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["tenant", "status", "missing", "workflow", "completion", "hidden"])
|
||||
def test_app_admission_rejects_unavailable_or_unsupported_apps(harness: _Harness, change: str) -> None:
|
||||
if change == "hidden":
|
||||
harness.add_agent(scope=AgentScope.WORKFLOW_ONLY)
|
||||
else:
|
||||
field_name, value = {
|
||||
"tenant": ("tenant_id", str(uuid4())),
|
||||
"status": ("status", "archived"),
|
||||
"missing": ("id", str(uuid4())),
|
||||
"workflow": ("mode", "workflow"),
|
||||
"completion": ("mode", "completion"),
|
||||
}[change]
|
||||
with harness.factory.begin() as session:
|
||||
if change == "status":
|
||||
session.execute(
|
||||
text("UPDATE apps SET status = :status WHERE id = :id"), {"status": value, "id": harness.target.id}
|
||||
)
|
||||
else:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values({field_name: value}))
|
||||
response = harness.get()
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["code"] == "app_not_found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope", [AgentScope.ROSTER, AgentScope.WORKFLOW_ONLY])
|
||||
def test_agent_resolves_existing_backing_app_with_its_debug_configuration(harness: _Harness, scope: AgentScope) -> None:
|
||||
harness.add_agent(scope=scope)
|
||||
with harness.factory.begin() as session:
|
||||
session.add(
|
||||
AgentDebugConversation(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
agent_id=harness.agent_id,
|
||||
app_id=harness.target.id,
|
||||
account_id=harness.account.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
conversation_id=harness.conversation.id,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
AgentConfigDraft(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
agent_id=harness.agent_id,
|
||||
account_id=harness.account.id,
|
||||
draft_owner_key=harness.account.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
config_snapshot=AgentSoulConfig.model_validate(
|
||||
{"app_features": {"suggested_questions_after_answer": {"enabled": True}}}
|
||||
),
|
||||
)
|
||||
)
|
||||
response = harness.get("agent")
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.get_json() == {"data": []}
|
||||
assert all(not session.in_transaction() and not session.identity_map for session in harness.sessions)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["missing_backing", "tenant", "agent_status", "app_status", "app_mode"])
|
||||
def test_agent_rejects_unavailable_runtime_without_creating_app(harness: _Harness, change: str) -> None:
|
||||
harness.add_agent(scope=AgentScope.WORKFLOW_ONLY, backing=change != "missing_backing")
|
||||
if change != "missing_backing":
|
||||
model, key, value = {
|
||||
"tenant": (Agent, "tenant_id", str(uuid4())),
|
||||
"agent_status": (Agent, "status", AgentStatus.ARCHIVED),
|
||||
"app_status": (App, "status", "archived"),
|
||||
"app_mode": (App, "mode", AppMode.CHAT),
|
||||
}[change]
|
||||
with harness.factory.begin() as session:
|
||||
if change == "app_status":
|
||||
session.execute(
|
||||
text("UPDATE apps SET status = :status WHERE id = :id"), {"status": value, "id": harness.target.id}
|
||||
)
|
||||
else:
|
||||
session.execute(update(model).values({key: value}))
|
||||
response = harness.get("agent")
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["code"] == "agent_not_found_error"
|
||||
with harness.factory() as session:
|
||||
assert session.scalar(select(func.count()).select_from(App)) == 1
|
||||
assert session.scalar(select(func.count()).select_from(AppModelConfig)) == 1
|
||||
if change == "missing_backing":
|
||||
assert session.scalar(select(Agent.backing_app_id)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["app", "agent"])
|
||||
def test_deleted_account_has_explicit_unauthorized_response(harness: _Harness, route: str) -> None:
|
||||
if route == "agent":
|
||||
harness.add_agent()
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(delete(Account).where(Account.id == harness.account.id))
|
||||
response = harness.get(route)
|
||||
assert response.status_code == HTTPStatus.UNAUTHORIZED
|
||||
assert response.get_json()["code"] == "unauthorized"
|
||||
assert response.get_json()["message"] == "Account no longer exists"
|
||||
assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"'
|
||||
|
||||
|
||||
def test_disabled_suggestions_have_specific_http_error(harness: _Harness) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(AppModelConfig).values(suggested_questions_after_answer='{"enabled":false}'))
|
||||
response = harness.get()
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
assert response.get_json()["code"] == "app_suggested_questions_after_answer_disabled"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["app", "agent"])
|
||||
def test_missing_published_agent_version_preserves_specific_http_error(harness: _Harness, route: str) -> None:
|
||||
harness.add_agent()
|
||||
response = harness.get(route)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
assert response.get_json() == {
|
||||
"code": "agent_version_not_found_error",
|
||||
"message": "Agent config version not found.",
|
||||
"status": HTTPStatus.NOT_FOUND,
|
||||
}
|
||||
assert all(not session.in_transaction() and not session.identity_map for session in harness.sessions)
|
||||
|
||||
|
||||
def test_model_resolution_preserves_pending_caller_session(harness: _Harness) -> None:
|
||||
with harness.flask_app.app_context():
|
||||
assert console_wraps._is_setup_completed()
|
||||
db.session.remove()
|
||||
caller_session = db.session()
|
||||
caller_app = caller_session.get(App, harness.target.id)
|
||||
assert caller_app is not None
|
||||
caller_app.name = "Pending caller edit"
|
||||
response = harness.get()
|
||||
assert db.session() is caller_session
|
||||
assert caller_session.in_transaction()
|
||||
assert caller_app in caller_session.dirty
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.get_json() == {"data": []}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entity", [Message, Conversation])
|
||||
@pytest.mark.parametrize("change", ["app_id", "from_account_id", "from_source"])
|
||||
def test_runtime_cannot_read_another_accounts_message_or_conversation(
|
||||
harness: _Harness, entity: type[Message] | type[Conversation], change: str
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(entity).values({change: "api" if change == "from_source" else str(uuid4())}))
|
||||
response = harness.get()
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["message"] == ("Message not found" if entity is Message else "Conversation not found")
|
||||
|
||||
|
||||
def test_agent_runtime_uses_current_accounts_personal_debug_draft(harness: _Harness) -> None:
|
||||
harness.add_agent()
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values({App.app_model_config_id: None}))
|
||||
session.add(
|
||||
AgentDebugConversation(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
agent_id=harness.agent_id,
|
||||
app_id=harness.target.id,
|
||||
account_id=harness.account.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
conversation_id=harness.conversation.id,
|
||||
)
|
||||
)
|
||||
for owner, prompt in [(harness.account.id, "My personal draft"), (str(uuid4()), "Other accounts secret draft")]:
|
||||
session.add(
|
||||
AgentConfigDraft(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
agent_id=harness.agent_id,
|
||||
account_id=owner,
|
||||
draft_owner_key=owner,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
config_snapshot=AgentSoulConfig.model_validate(
|
||||
{"app_features": {"suggested_questions_after_answer": {"enabled": True, "prompt": prompt}}}
|
||||
),
|
||||
)
|
||||
)
|
||||
response = harness.get("agent")
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.get_json() == {"data": []}
|
||||
context = harness.queries.prepare(
|
||||
app_id=harness.target.id,
|
||||
app_owner_tenant_id=harness.target.tenant_id,
|
||||
expected_app_mode=AppMode.AGENT,
|
||||
actor=MessageAccount(harness.account.id),
|
||||
invoke_from="debugger",
|
||||
message_id=harness.message.id,
|
||||
)
|
||||
assert context is not None
|
||||
assert context.config["prompt"] == "My personal draft"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("same_tenant", [True, False])
|
||||
def test_agent_runtime_preserves_conversation_binding_scope(harness: _Harness, same_tenant: bool) -> None:
|
||||
harness.add_agent()
|
||||
draft_id, binding_id = str(uuid4()), str(uuid4())
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values({App.app_model_config_id: None}))
|
||||
session.add(
|
||||
AgentConfigDraft(
|
||||
id=draft_id,
|
||||
tenant_id=harness.target.tenant_id,
|
||||
agent_id=harness.agent_id,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
config_snapshot=AgentSoulConfig.model_validate(
|
||||
{
|
||||
"app_features": {
|
||||
"suggested_questions_after_answer": {
|
||||
"enabled": True,
|
||||
"prompt": "Conversation bound questions",
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
AgentWorkspaceBinding(
|
||||
id=binding_id,
|
||||
tenant_id=harness.target.tenant_id if same_tenant else str(uuid4()),
|
||||
app_id=harness.target.id,
|
||||
workspace_id=str(uuid4()),
|
||||
agent_id=harness.agent_id,
|
||||
agent_config_version_id=draft_id,
|
||||
agent_config_version_kind=AgentConfigVersionKind.DRAFT,
|
||||
backend_binding_ref="existing-binding",
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
update(Conversation)
|
||||
.where(Conversation.id == harness.conversation.id)
|
||||
.values({Conversation.agent_workspace_binding_id: binding_id})
|
||||
)
|
||||
response = harness.get("agent")
|
||||
if same_tenant:
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
context = harness.queries.prepare(
|
||||
app_id=harness.target.id,
|
||||
app_owner_tenant_id=harness.target.tenant_id,
|
||||
expected_app_mode=AppMode.AGENT,
|
||||
actor=MessageAccount(harness.account.id),
|
||||
invoke_from="debugger",
|
||||
message_id=harness.message.id,
|
||||
)
|
||||
assert context is not None
|
||||
assert context.config["prompt"] == "Conversation bound questions"
|
||||
else:
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["code"] == "agent_version_not_found_error"
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
"""Installed-app suggested questions over real HTTP admission and SQLite services."""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass, replace
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Blueprint, Flask
|
||||
from redis import Redis
|
||||
from sqlalchemy import Connection, Engine, delete, event, update
|
||||
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
||||
from werkzeug.test import TestResponse
|
||||
|
||||
from constants import COOKIE_NAME_CSRF_TOKEN, HEADER_NAME_CSRF_TOKEN
|
||||
from controllers.console.explore.message import MessageSuggestedQuestionApi
|
||||
from controllers.console.wraps import _is_setup_completed
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_application_services import build_application_services
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_login import DifyLoginManager, bind_account_loader, load_user_from_request, unauthorized_handler
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.external_api import ExternalApi
|
||||
from libs.passport import PassportService
|
||||
from libs.token import generate_csrf_token
|
||||
from models import Account, Tenant
|
||||
from models.account import TenantAccountJoin, TenantAccountRole
|
||||
from models.enums import ConversationFromSource
|
||||
from models.model import App, AppMode, AppModelConfig, Conversation, DifySetup, InstalledApp, Message
|
||||
from models.provider import Provider, TenantDefaultModel
|
||||
from models.workflow import Workflow
|
||||
from repositories.installed_app_repository import SQLAlchemyInstalledAppRepository
|
||||
from services.installed_app_access_service import InstalledAppAccessService
|
||||
from tests.helpers.unit.model_factories import make_account, make_app, make_conversation, make_message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Harness:
|
||||
app: Flask
|
||||
account: Account
|
||||
target: App
|
||||
installation: InstalledApp
|
||||
conversation: Conversation
|
||||
message: Message
|
||||
factory: sessionmaker[Session]
|
||||
admission_factory: sessionmaker[Session]
|
||||
sessions: list[Session]
|
||||
|
||||
def get(self, *, installation_id: str | None = None, authenticated: bool = True, csrf: bool = True) -> TestResponse:
|
||||
client = self.app.test_client()
|
||||
token = generate_csrf_token(self.account.id)
|
||||
headers = {HEADER_NAME_CSRF_TOKEN: token} if csrf else {}
|
||||
if authenticated:
|
||||
headers["Authorization"] = f"Bearer {PassportService().issue({'user_id': self.account.id})}"
|
||||
client.set_cookie(COOKIE_NAME_CSRF_TOKEN, token)
|
||||
return client.get(
|
||||
f"/installed-apps/{installation_id or self.installation.id}/messages/{self.message.id}/suggested-questions",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def assert_closed(self, *, caller: Session | None = None) -> None:
|
||||
assert self.sessions
|
||||
assert all(
|
||||
not session.in_transaction() and not session.identity_map
|
||||
for session in self.sessions
|
||||
if session is not caller
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(
|
||||
_empty_model_provider_daemon: None,
|
||||
config_overrides: Callable[..., None],
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> Iterator[_Harness]:
|
||||
config_overrides(
|
||||
DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY,
|
||||
SECRET_KEY="installed-suggested-questions-test-key",
|
||||
LOGIN_DISABLED=False,
|
||||
INIT_PASSWORD="",
|
||||
CONSOLE_API_URL="http://localhost",
|
||||
CONSOLE_WEB_URL="http://localhost",
|
||||
COOKIE_DOMAIN="",
|
||||
)
|
||||
workspace = Tenant(name="Viewer workspace")
|
||||
owner = Tenant(name="App owner workspace")
|
||||
account = make_account(account_id=str(uuid4()))
|
||||
account.last_active_at = naive_utc_now()
|
||||
target = make_app(app_id=str(uuid4()), tenant_id=owner.id)
|
||||
installation = InstalledApp(
|
||||
tenant_id=workspace.id,
|
||||
app_id=target.id,
|
||||
app_owner_tenant_id=owner.id,
|
||||
position=0,
|
||||
is_pinned=False,
|
||||
last_used_at=None,
|
||||
)
|
||||
config = AppModelConfig(app_id=target.id, suggested_questions_after_answer='{"enabled":true}')
|
||||
conversation = make_conversation(
|
||||
conversation_id=str(uuid4()),
|
||||
app_id=target.id,
|
||||
inputs={},
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
)
|
||||
message = make_message(
|
||||
message_id=str(uuid4()),
|
||||
app_id=target.id,
|
||||
conversation_id=conversation.id,
|
||||
inputs={},
|
||||
message={},
|
||||
query="How?",
|
||||
answer="Like this.",
|
||||
message_unit_price=Decimal(0),
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
)
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add_all(
|
||||
[
|
||||
workspace,
|
||||
owner,
|
||||
account,
|
||||
target,
|
||||
installation,
|
||||
config,
|
||||
conversation,
|
||||
message,
|
||||
TenantAccountJoin(
|
||||
tenant_id=workspace.id, account_id=account.id, role=TenantAccountRole.OWNER, current=True
|
||||
),
|
||||
DifySetup(version="test"),
|
||||
# Real provider ID validation fails before plugin discovery or model I/O.
|
||||
Provider(tenant_id=owner.id, provider_name="invalid/provider", is_valid=True),
|
||||
TenantDefaultModel(
|
||||
tenant_id=owner.id,
|
||||
provider_name="invalid/provider",
|
||||
model_name="invalid-model",
|
||||
model_type=ModelType.LLM,
|
||||
),
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
target.app_model_config_id = config.id
|
||||
conversation.app_model_config_id = config.id
|
||||
|
||||
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
admission_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
app = Flask(__name__)
|
||||
app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
|
||||
db.init_app(app)
|
||||
login_manager = DifyLoginManager()
|
||||
login_manager.request_loader(load_user_from_request)
|
||||
login_manager.unauthorized_handler(unauthorized_handler)
|
||||
login_manager.init_app(app)
|
||||
redis_client = Redis()
|
||||
redis = RedisClientWrapper()
|
||||
redis.initialize(redis_client)
|
||||
services = build_application_services(
|
||||
database_client=factory,
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
initialization_password="",
|
||||
redis=redis,
|
||||
)
|
||||
# Give the installed-app query its own observable factory for stale-admission tests.
|
||||
access = InstalledAppAccessService(
|
||||
installed_apps=SQLAlchemyInstalledAppRepository(session_factory=admission_factory),
|
||||
is_user_allowed=services.webapp_access.is_user_allowed,
|
||||
get_access_modes=services.webapp_access.batch_get_access_modes,
|
||||
get_user_permissions=services.webapp_access.batch_get_user_permissions,
|
||||
)
|
||||
app.extensions["application_services"] = replace(
|
||||
services, installed_apps=replace(services.installed_apps, access=access)
|
||||
)
|
||||
bind_account_loader(app, services.accounts.identity.load_user)
|
||||
blueprint = Blueprint("console", __name__)
|
||||
api = ExternalApi(blueprint)
|
||||
api.add_resource(
|
||||
MessageSuggestedQuestionApi,
|
||||
"/installed-apps/<uuid:installed_app_id>/messages/<uuid:message_id>/suggested-questions",
|
||||
)
|
||||
app.register_blueprint(blueprint)
|
||||
_is_setup_completed.reset_success()
|
||||
with app.app_context():
|
||||
assert _is_setup_completed()
|
||||
db.session.remove()
|
||||
sessions: list[Session] = []
|
||||
|
||||
def track_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
sessions.append(session)
|
||||
|
||||
factories = (factory, admission_factory, sqlite_session_factory, db.session.session_factory)
|
||||
for observed in factories:
|
||||
event.listen(observed, "after_begin", track_session)
|
||||
yield _Harness(app, account, target, installation, conversation, message, factory, admission_factory, sessions)
|
||||
for observed in factories:
|
||||
event.remove(observed, "after_begin", track_session)
|
||||
assert redis_client.connection_pool._created_connections == 0
|
||||
_is_setup_completed.reset_success()
|
||||
redis_client.close()
|
||||
with app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
def _error(response: TestResponse, *, status: int, code: str) -> None:
|
||||
assert response.status_code == status
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
assert int(response.headers["Content-Length"]) == len(response.data)
|
||||
body = response.get_json()
|
||||
assert body["status"] == status
|
||||
assert body["code"] == code
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
def test_owner_provider_resolution_failure_returns_empty_questions(
|
||||
harness: _Harness, mode: AppMode, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
assert harness.target.tenant_id != harness.installation.tenant_id
|
||||
with harness.factory.begin() as session:
|
||||
app = session.get(App, harness.target.id)
|
||||
assert app is not None
|
||||
app.mode = mode
|
||||
if mode == AppMode.ADVANCED_CHAT:
|
||||
workflow = Workflow.new(
|
||||
tenant_id=app.tenant_id,
|
||||
app_id=app.id,
|
||||
type="chat",
|
||||
version="published",
|
||||
graph="{}",
|
||||
features='{"suggested_questions_after_answer":{"enabled":true}}',
|
||||
created_by=harness.account.id,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
session.add(workflow)
|
||||
session.flush()
|
||||
app.workflow_id = workflow.id
|
||||
response = harness.get()
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"data": []}
|
||||
failures = [
|
||||
record.exc_info[1]
|
||||
for record in caplog.records
|
||||
if record.name == "services.message_suggested_questions_generator" and record.exc_info is not None
|
||||
]
|
||||
assert len(failures) == 1
|
||||
assert isinstance(failures[0], ValueError)
|
||||
assert str(failures[0]) == "Invalid provider: invalid/provider"
|
||||
harness.assert_closed()
|
||||
with harness.factory() as session:
|
||||
installation = session.get(InstalledApp, harness.installation.id)
|
||||
assert installation is not None
|
||||
assert installation.last_used_at is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.COMPLETION, AppMode.WORKFLOW, AppMode.AGENT])
|
||||
def test_non_chat_modes_return_specific_error(harness: _Harness, mode: AppMode) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
|
||||
_error(harness.get(), status=400, code="not_chat_app")
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", ["installation", "workspace", "app"])
|
||||
def test_missing_or_foreign_installation_is_rejected(harness: _Harness, missing: str) -> None:
|
||||
installation_id = harness.installation.id
|
||||
if missing == "installation":
|
||||
installation_id = str(uuid4())
|
||||
else:
|
||||
with harness.factory.begin() as session:
|
||||
if missing == "workspace":
|
||||
session.execute(
|
||||
update(InstalledApp).where(InstalledApp.id == installation_id).values(tenant_id=str(uuid4()))
|
||||
)
|
||||
else:
|
||||
session.execute(delete(App).where(App.id == harness.target.id))
|
||||
_error(harness.get(installation_id=installation_id), status=404, code="installed_app_not_found")
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", ["login", "csrf"])
|
||||
def test_real_login_and_csrf_are_required(harness: _Harness, missing: str) -> None:
|
||||
response = harness.get(authenticated=missing != "login", csrf=missing != "csrf")
|
||||
_error(response, status=401, code="unauthorized")
|
||||
assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"'
|
||||
if missing == "login":
|
||||
assert harness.sessions == []
|
||||
else:
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", [Message, Conversation])
|
||||
@pytest.mark.parametrize("field", ["app_id", "from_account_id", "from_source"])
|
||||
def test_history_requires_complete_account_ownership(
|
||||
harness: _Harness, model: type[Message] | type[Conversation], field: str
|
||||
) -> None:
|
||||
record_id = harness.message.id if model is Message else harness.conversation.id
|
||||
value = ConversationFromSource.API if field == "from_source" else str(uuid4())
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(model).where(model.id == record_id).values({field: value}))
|
||||
_error(harness.get(), status=404, code="message_not_found" if model is Message else "conversation_not_found")
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resource", ["message", "conversation", "disabled", "config"])
|
||||
def test_missing_resources_and_feature_policy_keep_specific_errors(harness: _Harness, resource: str) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
if resource == "message":
|
||||
session.execute(delete(Message).where(Message.id == harness.message.id))
|
||||
elif resource == "conversation":
|
||||
session.execute(delete(Conversation).where(Conversation.id == harness.conversation.id))
|
||||
elif resource == "disabled":
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.app_id == harness.target.id)
|
||||
.values(suggested_questions_after_answer='{"enabled":false}')
|
||||
)
|
||||
else:
|
||||
session.execute(delete(AppModelConfig).where(AppModelConfig.app_id == harness.target.id))
|
||||
status, code = {
|
||||
"message": (404, "message_not_found"),
|
||||
"conversation": (404, "conversation_not_found"),
|
||||
"disabled": (403, "app_suggested_questions_after_answer_disabled"),
|
||||
"config": (500, "internal_server_error"),
|
||||
}[resource]
|
||||
_error(harness.get(), status=status, code=code)
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["mode", "owner", "app", "account"])
|
||||
def test_stale_admitted_resource_is_revalidated(harness: _Harness, change: str) -> None:
|
||||
def change_resource(_session: Session, _transaction: SessionTransaction) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
if change == "account":
|
||||
session.execute(delete(Account).where(Account.id == harness.account.id))
|
||||
elif change == "app":
|
||||
session.execute(delete(App).where(App.id == harness.target.id))
|
||||
else:
|
||||
values = {"mode": AppMode.COMPLETION} if change == "mode" else {"tenant_id": str(uuid4())}
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(values))
|
||||
|
||||
event.listen(harness.admission_factory, "after_transaction_end", change_resource, once=True)
|
||||
try:
|
||||
response = harness.get()
|
||||
finally:
|
||||
event.remove(harness.admission_factory, "after_transaction_end", change_resource)
|
||||
_error(
|
||||
response,
|
||||
status=401 if change == "account" else 400,
|
||||
code="unauthorized" if change == "account" else "app_unavailable",
|
||||
)
|
||||
if change == "account":
|
||||
assert response.get_json()["message"] == "Account no longer exists."
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
def test_removed_installation_takes_effect_on_next_request(harness: _Harness) -> None:
|
||||
def remove_installation(_session: Session, _transaction: SessionTransaction) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(delete(InstalledApp).where(InstalledApp.id == harness.installation.id))
|
||||
|
||||
event.listen(harness.admission_factory, "after_transaction_end", remove_installation, once=True)
|
||||
try:
|
||||
response = harness.get()
|
||||
finally:
|
||||
event.remove(harness.admission_factory, "after_transaction_end", remove_installation)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"data": []}
|
||||
_error(harness.get(), status=404, code="installed_app_not_found")
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
def test_malformed_config_has_opaque_error(harness: _Harness) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.app_id == harness.target.id)
|
||||
.values(suggested_questions_after_answer="private config diagnostic")
|
||||
)
|
||||
response = harness.get()
|
||||
_error(response, status=500, code="internal_server_error")
|
||||
assert "private config diagnostic" not in response.get_data(as_text=True)
|
||||
harness.assert_closed()
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
"""Trial suggested questions through HTTP, real ownership queries, and SQLite."""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Connection, Engine, delete, event, select, update
|
||||
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
||||
from werkzeug.test import TestResponse
|
||||
|
||||
import controllers.console.explore.trial as trial_module
|
||||
import controllers.console.wraps as console_wraps
|
||||
import libs.login as login_module
|
||||
from constants import COOKIE_NAME_CSRF_TOKEN, HEADER_NAME_CSRF_TOKEN
|
||||
from core.memory.token_buffer_memory import PreparedHistory
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_login import DifyLoginManager, unauthorized_handler
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from libs.external_api import ExternalApi
|
||||
from libs.token import generate_csrf_token
|
||||
from models import Account, AccountTrialAppRecord, App, AppMode, Conversation, Message, Tenant, TrialApp
|
||||
from models.account import AccountStatus
|
||||
from models.agent import Agent, AgentScope, AgentSource, AgentStatus
|
||||
from models.enums import ConversationFromSource
|
||||
from models.model import AppModelConfig, DifySetup
|
||||
from models.provider import Provider, TenantDefaultModel
|
||||
from repositories.message_repository import MessageRepository
|
||||
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
|
||||
from repositories.trial_app_repository import TrialAppRepository
|
||||
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
|
||||
from services.message_suggested_questions_queries import SuggestedQuestionsQuery
|
||||
from services.message_suggested_questions_service import (
|
||||
MessageSuggestedQuestionsService,
|
||||
)
|
||||
from services.recommended_app_query_service import RecommendedAppQueryService
|
||||
from services.trial_app_access_service import TrialAppAccessService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _TrialAppServices:
|
||||
access: TrialAppAccessService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ApplicationServices:
|
||||
trial_apps: _TrialAppServices
|
||||
recommended_app_queries: RecommendedAppQueryService
|
||||
message_suggested_questions: MessageSuggestedQuestionsService[PreparedHistory]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Harness:
|
||||
app: Flask
|
||||
account: Account
|
||||
target: App
|
||||
trial: TrialApp
|
||||
conversation: Conversation
|
||||
message: Message
|
||||
config: AppModelConfig
|
||||
factory: sessionmaker[Session]
|
||||
features: RecommendedAppQueryService
|
||||
read_sessions: list[Session]
|
||||
legacy_sessions: list[Session]
|
||||
|
||||
def get(self, *, app_id: str | None = None, message_id: str | None = None) -> TestResponse:
|
||||
client = self.app.test_client()
|
||||
csrf = generate_csrf_token(self.account.id)
|
||||
client.set_cookie(COOKIE_NAME_CSRF_TOKEN, csrf)
|
||||
return client.get(
|
||||
f"/trial-apps/{app_id or self.target.id}/messages/{message_id or self.message.id}/suggested-questions",
|
||||
headers={HEADER_NAME_CSRF_TOKEN: csrf},
|
||||
)
|
||||
|
||||
def assert_closed(self) -> None:
|
||||
assert all(
|
||||
not session.in_transaction() and not session.identity_map
|
||||
for session in self.read_sessions + self.legacy_sessions
|
||||
)
|
||||
|
||||
def usage(self) -> int | None:
|
||||
with self.factory() as session:
|
||||
return session.scalar(
|
||||
select(AccountTrialAppRecord.count).where(
|
||||
AccountTrialAppRecord.app_id == self.target.id,
|
||||
AccountTrialAppRecord.account_id == self.account.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(
|
||||
_empty_model_provider_daemon: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
config_overrides: Callable[..., None],
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
sqlite_engine: Engine,
|
||||
) -> Iterator[_Harness]:
|
||||
config_overrides(
|
||||
DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY,
|
||||
INIT_PASSWORD="",
|
||||
LOGIN_DISABLED=False,
|
||||
RBAC_ENABLED=False,
|
||||
SECRET_KEY="trial-suggested-question-test-secret",
|
||||
CONSOLE_WEB_URL="http://localhost",
|
||||
CONSOLE_API_URL="http://localhost",
|
||||
COOKIE_DOMAIN="",
|
||||
)
|
||||
console_wraps._is_setup_completed.reset_success()
|
||||
account = Account(name="Trial viewer", email="trial@example.com")
|
||||
account._current_tenant = Tenant(name="Viewer workspace")
|
||||
target = App(tenant_id=str(uuid4()), name="Trial app", mode=AppMode.CHAT, enable_site=True, enable_api=False)
|
||||
target.id = str(uuid4())
|
||||
trial = TrialApp(app_id=target.id, tenant_id=str(uuid4()), trial_limit=3)
|
||||
config = AppModelConfig(
|
||||
app_id=target.id,
|
||||
suggested_questions_after_answer='{"enabled":true,"prompt":"Suggest concise follow-ups"}',
|
||||
)
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add_all(
|
||||
[
|
||||
account,
|
||||
target,
|
||||
trial,
|
||||
config,
|
||||
DifySetup(version="test"),
|
||||
Provider(tenant_id=target.tenant_id, provider_name="invalid/provider", is_valid=True),
|
||||
TenantDefaultModel(
|
||||
tenant_id=target.tenant_id,
|
||||
provider_name="invalid/provider",
|
||||
model_name="invalid-model",
|
||||
model_type=ModelType.LLM,
|
||||
),
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
target.app_model_config_id = config.id
|
||||
conversation = Conversation(
|
||||
app_id=target.id,
|
||||
app_model_config_id=config.id,
|
||||
mode=AppMode.CHAT,
|
||||
name="Trial conversation",
|
||||
_inputs={},
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
)
|
||||
session.add(conversation)
|
||||
session.flush()
|
||||
message = Message(
|
||||
app_id=target.id,
|
||||
conversation_id=conversation.id,
|
||||
_inputs={},
|
||||
query="What is a trial?",
|
||||
message={},
|
||||
message_unit_price=Decimal(0),
|
||||
answer="A way to try an app.",
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
)
|
||||
session.add(message)
|
||||
|
||||
read_sessions: list[Session] = []
|
||||
read_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
|
||||
@event.listens_for(read_factory, "after_begin")
|
||||
def track_read(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
read_sessions.append(session)
|
||||
|
||||
trial_apps = TrialAppRepository(session_factory=read_factory)
|
||||
features = RecommendedAppQueryService(
|
||||
catalog=DatabaseRecommendedAppCatalogRepository(read_factory, redis=RedisClientWrapper()),
|
||||
trial_apps=trial_apps,
|
||||
trial_enabled=True,
|
||||
)
|
||||
queries = SuggestedQuestionsQuery(session_factory=read_factory, repository_factory=MessageRepository)
|
||||
services = _ApplicationServices(
|
||||
trial_apps=_TrialAppServices(access=TrialAppAccessService(apps=trial_apps)),
|
||||
recommended_app_queries=features,
|
||||
message_suggested_questions=MessageSuggestedQuestionsService(
|
||||
queries=queries, generator=SuggestedQuestionsGenerator()
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(login_module, "current_user", account)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
|
||||
db.init_app(app)
|
||||
app.extensions["application_services"] = services
|
||||
legacy_sessions: list[Session] = []
|
||||
|
||||
def track_legacy(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
legacy_sessions.append(session)
|
||||
|
||||
event.listen(db.session.session_factory, "after_begin", track_legacy)
|
||||
login_manager = DifyLoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.unauthorized_handler(unauthorized_handler)
|
||||
api = ExternalApi(app)
|
||||
api.add_resource(
|
||||
trial_module.TrialMessageSuggestedQuestionApi,
|
||||
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
|
||||
)
|
||||
yield _Harness(
|
||||
app,
|
||||
account,
|
||||
target,
|
||||
trial,
|
||||
conversation,
|
||||
message,
|
||||
config,
|
||||
sqlite_session_factory,
|
||||
features,
|
||||
read_sessions,
|
||||
legacy_sessions,
|
||||
)
|
||||
event.remove(db.session.session_factory, "after_begin", track_legacy)
|
||||
console_wraps._is_setup_completed.reset_success()
|
||||
with app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
def _assert_error(response: TestResponse, status: int, code: str, message: str | None = None) -> None:
|
||||
assert response.status_code == status, response.get_json()
|
||||
body = response.get_json()
|
||||
assert body["code"] == code
|
||||
assert body["status"] == status
|
||||
assert body["message"]
|
||||
if message is not None:
|
||||
assert body["message"] == message
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT])
|
||||
def test_model_resolution_failure_keeps_empty_response_and_usage(
|
||||
harness: _Harness, mode: AppMode, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
|
||||
session.execute(update(Conversation).where(Conversation.id == harness.conversation.id).values(mode=mode))
|
||||
session.add_all(
|
||||
[
|
||||
AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=1),
|
||||
AccountTrialAppRecord(app_id=harness.target.id, account_id=str(uuid4()), count=3),
|
||||
]
|
||||
)
|
||||
response = harness.get()
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json() == {"data": []}
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
assert int(response.headers["Content-Length"]) == len(response.data)
|
||||
assert any(
|
||||
record.exc_info
|
||||
and isinstance(record.exc_info[1], ValueError)
|
||||
and "Invalid provider: invalid/provider" in str(record.exc_info[1])
|
||||
for record in caplog.records
|
||||
)
|
||||
assert harness.target.tenant_id not in {harness.trial.tenant_id, harness.account.current_tenant_id}
|
||||
assert harness.usage() == 1
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
def test_missing_published_agent_version_has_specific_error(harness: _Harness) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.AGENT))
|
||||
session.add(
|
||||
Agent(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
app_id=harness.target.id,
|
||||
name="Trial Agent",
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
|
||||
_assert_error(harness.get(), 404, "agent_version_not_found_error", "Agent config version not found.")
|
||||
assert harness.usage() is None
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entity", ["message", "conversation"])
|
||||
@pytest.mark.parametrize("mismatch", ["missing", "app", "account", "source", "end-user"])
|
||||
def test_message_and_conversation_require_complete_owner_chain(harness: _Harness, entity: str, mismatch: str) -> None:
|
||||
message_id = harness.message.id
|
||||
with harness.factory.begin() as session:
|
||||
record = (
|
||||
session.get(Message, harness.message.id)
|
||||
if entity == "message"
|
||||
else session.get(Conversation, harness.conversation.id)
|
||||
)
|
||||
assert record is not None
|
||||
match mismatch:
|
||||
case "missing":
|
||||
if entity == "message":
|
||||
message_id = str(uuid4())
|
||||
else:
|
||||
session.delete(record)
|
||||
case "app":
|
||||
record.app_id = str(uuid4())
|
||||
case "account":
|
||||
record.from_account_id = str(uuid4())
|
||||
case "source":
|
||||
record.from_source = ConversationFromSource.API
|
||||
case "end-user":
|
||||
record.from_end_user_id = str(uuid4())
|
||||
|
||||
response = harness.get(message_id=message_id)
|
||||
|
||||
_assert_error(response, 404, "not_found", "Message not found" if entity == "message" else "Conversation not found")
|
||||
assert harness.usage() is None
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
def test_deleted_conversation_is_not_visible(harness: _Harness) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(Conversation).where(Conversation.id == harness.conversation.id).values(is_deleted=True))
|
||||
_assert_error(harness.get(), 404, "not_found", "Conversation not found")
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config", [None, '{"enabled":false}'])
|
||||
def test_disabled_or_unset_suggestions_have_specific_error(harness: _Harness, config: str | None) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.id == harness.config.id)
|
||||
.values(suggested_questions_after_answer=config)
|
||||
)
|
||||
_assert_error(harness.get(), 403, "app_suggested_questions_after_answer_disabled")
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.COMPLETION, AppMode.WORKFLOW])
|
||||
def test_unsupported_app_modes_fail_before_runtime(harness: _Harness, mode: AppMode) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
|
||||
_assert_error(harness.get(), 400, "not_chat_app")
|
||||
assert len(harness.read_sessions) == 1
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("denial", "status", "code"),
|
||||
[
|
||||
("setup", 401, "not_setup"),
|
||||
("feature", 403, "trial_app_feature_disabled"),
|
||||
("missing_trial", 403, "trial_app_not_allowed"),
|
||||
("missing_app", 403, "trial_app_not_allowed"),
|
||||
("quota", 403, "trial_app_limit_exceeded"),
|
||||
("uninitialized", 400, "account_not_initialized"),
|
||||
("unauthenticated", 401, "unauthorized"),
|
||||
],
|
||||
)
|
||||
def test_admission_blocks_before_runtime(
|
||||
harness: _Harness, monkeypatch: pytest.MonkeyPatch, denial: str, status: int, code: str
|
||||
) -> None:
|
||||
if denial == "setup":
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(delete(DifySetup))
|
||||
console_wraps._is_setup_completed.reset_success()
|
||||
elif denial == "feature":
|
||||
harness.features._trial_enabled = False
|
||||
elif denial == "uninitialized":
|
||||
harness.account.status = AccountStatus.UNINITIALIZED
|
||||
elif denial == "unauthenticated":
|
||||
monkeypatch.setattr(login_module, "current_user", None)
|
||||
else:
|
||||
with harness.factory.begin() as session:
|
||||
if denial == "missing_trial":
|
||||
session.execute(delete(TrialApp).where(TrialApp.app_id == harness.target.id))
|
||||
elif denial == "missing_app":
|
||||
session.execute(delete(App).where(App.id == harness.target.id))
|
||||
else:
|
||||
session.add(AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=3))
|
||||
response = harness.get()
|
||||
if denial == "unauthenticated":
|
||||
assert response.status_code == 401
|
||||
assert response.get_json() == {"code": "unauthorized", "message": "Unauthorized.", "status": 401}
|
||||
else:
|
||||
_assert_error(response, status, code)
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
def test_model_resolution_preserves_outer_request_session(harness: _Harness) -> None:
|
||||
with harness.app.app_context():
|
||||
# Bootstrap uses the scoped session; complete that real query before the caller edits its entity.
|
||||
assert console_wraps._is_setup_completed()
|
||||
db.session.remove()
|
||||
outer_session = db.session()
|
||||
app_model = outer_session.get(App, harness.target.id)
|
||||
assert app_model is not None
|
||||
app_model.name = "Pending caller change"
|
||||
response = harness.get()
|
||||
assert response.status_code == 200
|
||||
assert db.session() is outer_session
|
||||
assert outer_session.in_transaction()
|
||||
assert app_model in outer_session.dirty
|
||||
assert app_model.name == "Pending caller change"
|
||||
harness.assert_closed()
|
||||
with harness.factory() as session:
|
||||
saved_app = session.get(App, harness.target.id)
|
||||
assert saved_app is not None
|
||||
assert saved_app.name == "Trial app"
|
||||
|
||||
|
||||
def test_advanced_chat_without_published_workflow_keeps_empty_success(harness: _Harness) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.ADVANCED_CHAT))
|
||||
response = harness.get()
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"data": []}
|
||||
assert len(harness.read_sessions) == 2
|
||||
assert harness.usage() is None
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
def test_deleted_account_has_explicit_unauthorized_response(harness: _Harness) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(delete(Account).where(Account.id == harness.account.id))
|
||||
response = harness.get()
|
||||
_assert_error(response, 401, "unauthorized", "Account no longer exists.")
|
||||
assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"'
|
||||
assert harness.usage() is None
|
||||
harness.assert_closed()
|
||||
+751
@@ -0,0 +1,751 @@
|
||||
"""Service API suggested questions with real admission, end-user provisioning, and SQLite."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import FrozenInstanceError, asdict, dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager, current_user
|
||||
from sqlalchemy import Connection, Engine, delete, event, select, text
|
||||
from sqlalchemy.orm import Session, SessionTransaction, object_session, sessionmaker
|
||||
from werkzeug.test import TestResponse
|
||||
|
||||
from constants.resource_access_token import ResourceAccessTokenResourceType
|
||||
from controllers.service_api.app.message import MessageSuggestedApi
|
||||
from controllers.service_api.flask_admission import service_api_end_user_admission
|
||||
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
|
||||
from extensions.application_services.resource_access_token import build_resource_access_token_service
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from libs.external_api import ExternalApi
|
||||
from machinery.context import RequestContext, ServiceApiEndUserContext
|
||||
from models import Tenant, TenantStatus
|
||||
from models.agent import Agent, AgentScope, AgentSource
|
||||
from models.enums import ApiTokenType, ConversationFromSource, EndUserType
|
||||
from models.model import ApiToken, App, AppMode, AppModelConfig, Conversation, EndUser, Message
|
||||
from models.provider import Provider, TenantDefaultModel
|
||||
from models.resource_access_token import ResourceAccessToken, ResourceAccessTokenRelation
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
from repositories.app_definition_query_repository import AppDefinitionQueryRepository
|
||||
from repositories.app_scoped_end_user_repository import AppScopedEndUserRepo
|
||||
from repositories.message_repository import MessageRepository
|
||||
from services.app_definition_query_service import AppDefinitionQueryService, AppDefinitionUnavailableError
|
||||
from services.app_scoped_end_user_service import AppScopedEndUserService
|
||||
from services.auth.resource_access_token_contracts import ResourceAccessTokenCreateResult, ResourceAccessTokenResource
|
||||
from services.errors.app import AppAbnormalStatusError, AppApiDisabledError
|
||||
from services.errors.workspace import WorkspaceArchivedError, WorkspaceNotFoundError
|
||||
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
|
||||
from services.message_suggested_questions_queries import SuggestedQuestionsQuery
|
||||
from services.message_suggested_questions_service import (
|
||||
MessageSuggestedQuestions,
|
||||
MessageSuggestedQuestionsService,
|
||||
)
|
||||
from services.resource_access_token_service import ResourceAccessTokenService
|
||||
from tests.helpers.unit.model_factories import make_app, make_conversation, make_end_user, make_message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _EndUsers:
|
||||
commands: AppScopedEndUserService[EndUser]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Services:
|
||||
app_definitions: AppDefinitionQueryService
|
||||
message_suggested_questions: MessageSuggestedQuestions
|
||||
app_scoped_end_users: _EndUsers
|
||||
resource_access_tokens: ResourceAccessTokenService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Harness:
|
||||
app: Flask
|
||||
target: App
|
||||
tenant: Tenant
|
||||
end_user: EndUser
|
||||
conversation: Conversation
|
||||
config: AppModelConfig
|
||||
provision_factory: sessionmaker[Session]
|
||||
factory: sessionmaker[Session]
|
||||
message_id: str
|
||||
admissions: list[Session]
|
||||
provisions: list[Session]
|
||||
queries: list[Session]
|
||||
scoped_sessions: list[Session]
|
||||
|
||||
def get(
|
||||
self,
|
||||
*,
|
||||
user: str | None = "alice",
|
||||
authorization: str | None = "Bearer test-token",
|
||||
requested_app_id: str | None = None,
|
||||
) -> TestResponse:
|
||||
headers: dict[str, str] = {"Authorization": authorization} if authorization is not None else {}
|
||||
if requested_app_id is not None:
|
||||
headers["X-Dify-App-ID"] = requested_app_id
|
||||
query: dict[str, str] = {"user": user} if user is not None else {}
|
||||
return self.app.test_client().get(f"/messages/{self.message_id}/suggested", query_string=query, headers=headers)
|
||||
|
||||
def assert_closed(self, *, caller: Session | None = None) -> None:
|
||||
assert all(
|
||||
not session.in_transaction() and not session.identity_map
|
||||
for session in self.admissions + self.provisions + self.queries + self.scoped_sessions
|
||||
if session is not caller
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(
|
||||
_empty_model_provider_daemon: None,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> Iterator[_Harness]:
|
||||
tenant = Tenant(name="API workspace")
|
||||
target = make_app(app_id=str(uuid4()), tenant_id=tenant.id)
|
||||
end_user = make_end_user(
|
||||
end_user_id=str(uuid4()),
|
||||
tenant_id=tenant.id,
|
||||
app_id=target.id,
|
||||
end_user_type=EndUserType.SERVICE_API,
|
||||
session_id="alice",
|
||||
external_user_id="alice",
|
||||
)
|
||||
config = AppModelConfig(app_id=target.id, suggested_questions_after_answer='{"enabled": true}')
|
||||
config.id = str(uuid4())
|
||||
target.app_model_config_id = config.id
|
||||
conversation = make_conversation(
|
||||
conversation_id=str(uuid4()),
|
||||
app_id=target.id,
|
||||
inputs={},
|
||||
from_source=ConversationFromSource.API,
|
||||
from_end_user_id=end_user.id,
|
||||
)
|
||||
conversation.app_model_config_id = config.id
|
||||
message = make_message(
|
||||
message_id=str(uuid4()),
|
||||
app_id=target.id,
|
||||
conversation_id=conversation.id,
|
||||
inputs={},
|
||||
query="How does this work?",
|
||||
message={},
|
||||
answer="Like this.",
|
||||
message_unit_price=Decimal(0),
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
from_end_user_id=end_user.id,
|
||||
)
|
||||
token = ApiToken(app_id=target.id, tenant_id=tenant.id, type=ApiTokenType.APP, token="test-token")
|
||||
with sqlite_session_factory.begin() as session:
|
||||
# Fail the actual provider ID validation before plugin discovery can run.
|
||||
provider = Provider(tenant_id=tenant.id, provider_name="invalid/provider", is_valid=True)
|
||||
session.add(
|
||||
TenantDefaultModel(
|
||||
tenant_id=tenant.id,
|
||||
provider_name="invalid/provider",
|
||||
model_name="invalid-model",
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
)
|
||||
session.add_all([tenant, target, end_user, config, conversation, message, token, provider])
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
|
||||
db.init_app(app)
|
||||
LoginManager(app)
|
||||
admissions: list[Session] = []
|
||||
provisions: list[Session] = []
|
||||
query_sessions: list[Session] = []
|
||||
scoped_sessions: list[Session] = []
|
||||
admission_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
provision_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
query_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
|
||||
def track_admission(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
admissions.append(session)
|
||||
|
||||
def track_provision(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
# End-user lookup/creation must not overlap the admission transaction.
|
||||
assert all(not admission.in_transaction() and not admission.identity_map for admission in admissions)
|
||||
provisions.append(session)
|
||||
|
||||
def track_query(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
query_sessions.append(session)
|
||||
|
||||
def track_scoped(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
scoped_sessions.append(session)
|
||||
|
||||
event.listen(admission_factory, "after_begin", track_admission)
|
||||
event.listen(provision_factory, "after_begin", track_provision)
|
||||
event.listen(query_factory, "after_begin", track_query)
|
||||
event.listen(db.session.session_factory, "after_begin", track_scoped)
|
||||
|
||||
queries = SuggestedQuestionsQuery(session_factory=query_factory, repository_factory=MessageRepository)
|
||||
service = MessageSuggestedQuestionsService(queries=queries, generator=SuggestedQuestionsGenerator())
|
||||
app.extensions["application_services"] = _Services(
|
||||
app_definitions=AppDefinitionQueryService(
|
||||
definitions=AppDefinitionQueryRepository(session_factory=admission_factory), builtin_icon_url_prefix=""
|
||||
),
|
||||
message_suggested_questions=service,
|
||||
app_scoped_end_users=_EndUsers(
|
||||
AppScopedEndUserService(end_users=AppScopedEndUserRepo(session_factory=provision_factory))
|
||||
),
|
||||
resource_access_tokens=build_resource_access_token_service(database_client=admission_factory),
|
||||
)
|
||||
api = ExternalApi(app)
|
||||
api.add_resource(MessageSuggestedApi, "/messages/<uuid:message_id>/suggested")
|
||||
yield _Harness(
|
||||
app,
|
||||
target,
|
||||
tenant,
|
||||
end_user,
|
||||
conversation,
|
||||
config,
|
||||
provision_factory,
|
||||
sqlite_session_factory,
|
||||
message.id,
|
||||
admissions,
|
||||
provisions,
|
||||
query_sessions,
|
||||
scoped_sessions,
|
||||
)
|
||||
event.remove(admission_factory, "after_begin", track_admission)
|
||||
event.remove(provision_factory, "after_begin", track_provision)
|
||||
event.remove(query_factory, "after_begin", track_query)
|
||||
event.remove(db.session.session_factory, "after_begin", track_scoped)
|
||||
with app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
def test_admitted_identity_and_mode_return_empty_questions_for_invalid_provider_config(
|
||||
harness: _Harness, mode: AppMode, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
target = session.get(App, harness.target.id)
|
||||
assert target is not None
|
||||
target.mode = mode
|
||||
if mode == AppMode.ADVANCED_CHAT:
|
||||
workflow = Workflow(
|
||||
tenant_id=harness.tenant.id,
|
||||
app_id=target.id,
|
||||
type=WorkflowType.CHAT,
|
||||
version="published",
|
||||
graph='{"nodes":[],"edges":[]}',
|
||||
features='{"suggested_questions_after_answer":{"enabled":true}}',
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
session.add(workflow)
|
||||
session.flush()
|
||||
target.workflow_id = workflow.id
|
||||
response = harness.get()
|
||||
assert response.status_code == 200
|
||||
assert response.json == {"result": "success", "data": []}
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
failures = [
|
||||
record.exc_info[1]
|
||||
for record in caplog.records
|
||||
if record.name == "services.message_suggested_questions_generator" and record.exc_info is not None
|
||||
]
|
||||
assert len(failures) == 1
|
||||
assert isinstance(failures[0], ValueError)
|
||||
assert str(failures[0]) == "Invalid provider: invalid/provider"
|
||||
assert len(harness.queries) == 1
|
||||
with harness.factory() as session:
|
||||
end_user = session.scalar(select(EndUser).where(EndUser.app_id == harness.target.id))
|
||||
assert end_user is not None
|
||||
assert end_user.type == EndUserType.SERVICE_API
|
||||
assert end_user.external_user_id == "alice"
|
||||
assert end_user.id == harness.end_user.id
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
def test_existing_user_is_reused_within_the_token_app_only(harness: _Harness) -> None:
|
||||
decoy = make_end_user(
|
||||
end_user_id=str(uuid4()),
|
||||
tenant_id=str(uuid4()),
|
||||
app_id=str(uuid4()),
|
||||
end_user_type=EndUserType.SERVICE_API,
|
||||
session_id="alice",
|
||||
external_user_id="alice",
|
||||
)
|
||||
with harness.factory.begin() as session:
|
||||
session.add(decoy)
|
||||
assert harness.get().status_code == 200
|
||||
with harness.factory() as session:
|
||||
assert len(session.scalars(select(EndUser)).all()) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("authorization", [None, "Basic test-token"])
|
||||
def test_invalid_authorization_stops_before_database_admission(harness: _Harness, authorization: str | None) -> None:
|
||||
response = harness.get(authorization=authorization)
|
||||
assert response.status_code == 401
|
||||
assert response.json is not None
|
||||
assert response.json["code"] == "unauthorized"
|
||||
assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"'
|
||||
assert harness.admissions == harness.provisions == harness.queries == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user", [None, ""])
|
||||
def test_required_user_rejected_before_provisioning(harness: _Harness, user: str | None) -> None:
|
||||
response = harness.get(user=user)
|
||||
assert response.status_code == 400
|
||||
assert response.json is not None
|
||||
assert response.json["code"] == "invalid_param"
|
||||
assert response.json["message"] == "Arg user must be provided."
|
||||
assert harness.provisions == harness.queries == []
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure", "domain_error", "code", "message"),
|
||||
[
|
||||
("missing_app", AppDefinitionUnavailableError, "app_not_found", "The app no longer exists."),
|
||||
("null_app", AppDefinitionUnavailableError, "app_not_found", "The app no longer exists."),
|
||||
("abnormal_app", AppAbnormalStatusError, "app_abnormal_status", "The app's status is abnormal."),
|
||||
("empty_app_status", AppAbnormalStatusError, "app_abnormal_status", "The app's status is abnormal."),
|
||||
("disabled_api", AppApiDisabledError, "app_api_disabled", "The app's API service has been disabled."),
|
||||
("archived_tenant", WorkspaceArchivedError, "workspace_archived", "The workspace's status is archived."),
|
||||
("missing_tenant", WorkspaceNotFoundError, "workspace_not_found", "Tenant does not exist."),
|
||||
],
|
||||
)
|
||||
def test_app_and_workspace_admission(
|
||||
harness: _Harness,
|
||||
failure: Literal[
|
||||
"missing_app",
|
||||
"null_app",
|
||||
"abnormal_app",
|
||||
"empty_app_status",
|
||||
"disabled_api",
|
||||
"archived_tenant",
|
||||
"missing_tenant",
|
||||
],
|
||||
domain_error: type[Exception],
|
||||
code: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
target = session.get(App, harness.target.id)
|
||||
tenant = session.get(Tenant, harness.tenant.id)
|
||||
assert target is not None
|
||||
assert tenant is not None
|
||||
match failure:
|
||||
case "missing_app":
|
||||
session.add(make_app(app_id=str(uuid4()), tenant_id=tenant.id))
|
||||
session.delete(target)
|
||||
case "null_app":
|
||||
token = session.scalar(select(ApiToken).where(ApiToken.token == "test-token"))
|
||||
assert token is not None
|
||||
token.app_id = None
|
||||
case "abnormal_app" | "empty_app_status":
|
||||
# Bypass EnumText validation to exercise malformed persisted status values.
|
||||
session.execute(
|
||||
text("UPDATE apps SET status = :status WHERE id = :app_id"),
|
||||
{"status": "disabled" if failure == "abnormal_app" else "", "app_id": target.id},
|
||||
)
|
||||
case "disabled_api":
|
||||
target.enable_api = False
|
||||
case "archived_tenant":
|
||||
tenant.status = TenantStatus.ARCHIVE
|
||||
case "missing_tenant":
|
||||
session.delete(tenant)
|
||||
services = harness.app.extensions["application_services"]
|
||||
assert isinstance(services, _Services)
|
||||
with pytest.raises(domain_error):
|
||||
services.app_definitions.get_service_api_app(None if failure == "null_app" else harness.target.id)
|
||||
harness.assert_closed()
|
||||
|
||||
response = harness.get(user="new-user")
|
||||
assert response.status_code == 403
|
||||
assert response.json == {"code": code, "message": message, "status": 403}
|
||||
assert harness.provisions == harness.queries == []
|
||||
with harness.factory() as session:
|
||||
assert session.scalars(select(EndUser.id)).all() == [harness.end_user.id]
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.COMPLETION, AppMode.WORKFLOW])
|
||||
def test_unsupported_mode_does_not_invoke_questions(harness: _Harness, mode: AppMode) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
target = session.get(App, harness.target.id)
|
||||
assert target is not None
|
||||
target.mode = mode
|
||||
response = harness.get()
|
||||
assert response.status_code == 400
|
||||
assert response.json is not None
|
||||
assert response.json["code"] == "not_chat_app"
|
||||
assert harness.queries == []
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure", "status", "code", "message"),
|
||||
[
|
||||
("message", 404, "not_found", "Message Not Exists."),
|
||||
("conversation", 404, "not_found", "Conversation not found"),
|
||||
("agent_version", 404, "agent_version_not_found_error", "Agent config version not found."),
|
||||
("disabled", 403, "app_suggested_questions_after_answer_disabled", ""),
|
||||
],
|
||||
)
|
||||
def test_domain_errors_have_specific_http_codes(
|
||||
harness: _Harness,
|
||||
failure: Literal["message", "conversation", "agent_version", "disabled"],
|
||||
status: int,
|
||||
code: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
match failure:
|
||||
case "message":
|
||||
message_record = session.get(Message, harness.message_id)
|
||||
assert message_record is not None
|
||||
session.delete(message_record)
|
||||
case "conversation":
|
||||
conversation = session.get(Conversation, harness.conversation.id)
|
||||
assert conversation is not None
|
||||
session.delete(conversation)
|
||||
case "disabled":
|
||||
config = session.get(AppModelConfig, harness.config.id)
|
||||
assert config is not None
|
||||
config.suggested_questions_after_answer = '{"enabled":false}'
|
||||
case "agent_version":
|
||||
target = session.get(App, harness.target.id)
|
||||
assert target is not None
|
||||
target.mode = AppMode.AGENT
|
||||
session.add(
|
||||
Agent(
|
||||
tenant_id=harness.tenant.id,
|
||||
app_id=target.id,
|
||||
name="Missing published version",
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id=str(uuid4()),
|
||||
)
|
||||
)
|
||||
response = harness.get()
|
||||
assert response.status_code == status
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
assert response.json is not None
|
||||
assert response.json["code"] == code
|
||||
assert response.json["status"] == status
|
||||
if message:
|
||||
assert response.json["message"] == message
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("change", "status", "code", "message"),
|
||||
[
|
||||
("app_mode", 400, "app_unavailable", ""),
|
||||
("end_user_tenant", 404, "not_found", "End user not found"),
|
||||
],
|
||||
)
|
||||
def test_scope_changes_after_admission_have_specific_http_codes(
|
||||
harness: _Harness,
|
||||
change: Literal["app_mode", "end_user_tenant"],
|
||||
status: int,
|
||||
code: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
def change_scope(_session: Session, _transaction: SessionTransaction) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
if change == "app_mode":
|
||||
target = session.get(App, harness.target.id)
|
||||
assert target is not None
|
||||
target.mode = AppMode.ADVANCED_CHAT
|
||||
else:
|
||||
end_user = session.get(EndUser, harness.end_user.id)
|
||||
assert end_user is not None
|
||||
end_user.tenant_id = str(uuid4())
|
||||
|
||||
event.listen(harness.provision_factory, "after_transaction_end", change_scope, once=True)
|
||||
try:
|
||||
response = harness.get()
|
||||
finally:
|
||||
event.remove(harness.provision_factory, "after_transaction_end", change_scope)
|
||||
assert response.status_code == status
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
assert response.json is not None
|
||||
assert response.json["code"] == code
|
||||
assert response.json["status"] == status
|
||||
if message:
|
||||
assert response.json["message"] == message
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
def test_malformed_configuration_returns_an_opaque_internal_error(harness: _Harness) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
config = session.get(AppModelConfig, harness.config.id)
|
||||
assert config is not None
|
||||
config.suggested_questions_after_answer = "private diagnostic: invalid JSON"
|
||||
response = harness.get()
|
||||
assert response.status_code == 500
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
assert response.json is not None
|
||||
assert response.json["code"] == "internal_server_error"
|
||||
assert response.json["status"] == 500
|
||||
assert "private diagnostic" not in response.get_data(as_text=True)
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
def test_caller_session_and_pending_edits_are_preserved(harness: _Harness) -> None:
|
||||
with harness.app.app_context():
|
||||
caller = db.session()
|
||||
target = caller.get(App, harness.target.id)
|
||||
assert target is not None
|
||||
target.name = "Pending caller change"
|
||||
transaction = caller.get_transaction()
|
||||
response = harness.get()
|
||||
assert response.status_code == 200
|
||||
assert response.json == {"result": "success", "data": []}
|
||||
assert db.session() is caller
|
||||
assert caller.get_transaction() is transaction
|
||||
assert target in caller.dirty
|
||||
assert target.name == "Pending caller change"
|
||||
harness.assert_closed(caller=caller)
|
||||
caller.rollback()
|
||||
with harness.factory() as session:
|
||||
target = session.get(App, harness.target.id)
|
||||
assert target is not None
|
||||
assert target.name == harness.target.name
|
||||
|
||||
|
||||
def test_default_admission_keeps_legacy_app_attached(harness: _Harness) -> None:
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY, required=True))
|
||||
def view(*, app_model: App, end_user: EndUser) -> str:
|
||||
assert object_session(app_model) is db.session()
|
||||
assert app_model.id == harness.target.id
|
||||
assert end_user.app_id == app_model.id
|
||||
return "legacy"
|
||||
|
||||
harness.app.add_url_rule("/legacy", view_func=view)
|
||||
with harness.app.test_request_context("/legacy?user=alice", headers={"Authorization": "Bearer test-token"}):
|
||||
assert harness.app.dispatch_request() == "legacy"
|
||||
assert db.session().in_transaction()
|
||||
assert harness.admissions == []
|
||||
|
||||
|
||||
def test_request_identity_remains_installed_after_generation(harness: _Harness) -> None:
|
||||
with harness.app.test_client() as client:
|
||||
response = client.get(
|
||||
f"/messages/{harness.message_id}/suggested?user=alice", headers={"Authorization": "Bearer test-token"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json == {"result": "success", "data": []}
|
||||
assert current_user.id == harness.end_user.id
|
||||
assert current_user.app_id == harness.target.id
|
||||
assert current_user.tenant_id == harness.tenant.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fetch_from", [WhereisUserArg.QUERY, WhereisUserArg.JSON, WhereisUserArg.FORM])
|
||||
def test_admission_injects_immutable_scope_and_provisions_request_user(
|
||||
harness: _Harness,
|
||||
fetch_from: WhereisUserArg,
|
||||
) -> None:
|
||||
class AdmittedView:
|
||||
@service_api_end_user_admission(fetch_user_arg=FetchUserArg(fetch_from=fetch_from, required=True))
|
||||
def get(self, context: ServiceApiEndUserContext) -> dict[str, str]:
|
||||
assert isinstance(context, ServiceApiEndUserContext)
|
||||
harness.assert_closed()
|
||||
assert current_user.id == context.end_user_id
|
||||
payload = asdict(context)
|
||||
for field_name in payload:
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
setattr(context, field_name, "untrusted")
|
||||
return payload
|
||||
|
||||
harness.app.add_url_rule("/admitted", view_func=AdmittedView().get, methods=["GET", "POST"])
|
||||
client = harness.app.test_client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
match fetch_from:
|
||||
case WhereisUserArg.QUERY:
|
||||
response = client.get("/admitted?user=bob", headers=headers)
|
||||
case WhereisUserArg.JSON:
|
||||
response = client.post("/admitted", json={"user": "bob"}, headers=headers)
|
||||
case WhereisUserArg.FORM:
|
||||
response = client.post("/admitted", data={"user": "bob"}, headers=headers)
|
||||
assert response.status_code == 200
|
||||
with harness.factory() as session:
|
||||
end_user = session.scalar(
|
||||
select(EndUser).where(EndUser.app_id == harness.target.id, EndUser.external_user_id == "bob")
|
||||
)
|
||||
assert end_user is not None
|
||||
assert end_user.session_id == "bob"
|
||||
assert response.json == {
|
||||
"app_id": harness.target.id,
|
||||
"tenant_id": harness.tenant.id,
|
||||
"app_mode": harness.target.mode,
|
||||
"end_user_id": end_user.id,
|
||||
}
|
||||
|
||||
|
||||
def _create_resource_token(harness: _Harness, *, app_ids: tuple[str, ...]) -> ResourceAccessTokenCreateResult:
|
||||
services = harness.app.extensions["application_services"]
|
||||
assert isinstance(services, _Services)
|
||||
result = services.resource_access_tokens.create(
|
||||
RequestContext(
|
||||
request_id="resource-token-regression",
|
||||
trace_id=None,
|
||||
account_id=str(uuid4()),
|
||||
active_workspace_id=harness.tenant.id,
|
||||
),
|
||||
name="Suggested questions integration",
|
||||
resources=tuple(ResourceAccessTokenResource(ResourceAccessTokenResourceType.APP, app_id) for app_id in app_ids),
|
||||
)
|
||||
assert result.token.startswith("sk-")
|
||||
assert all(row.last_used_at is None for row in result.rows)
|
||||
harness.assert_closed()
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("multiple_apps", [False, True], ids=["implicit-single-app", "explicit-multiple-apps"])
|
||||
def test_resource_access_token_admits_bound_app_and_records_usage(harness: _Harness, multiple_apps: bool) -> None:
|
||||
app_ids = (harness.target.id,)
|
||||
if multiple_apps:
|
||||
other = make_app(app_id=str(uuid4()), tenant_id=harness.tenant.id)
|
||||
with harness.factory.begin() as session:
|
||||
session.add(other)
|
||||
app_ids = (other.id, harness.target.id)
|
||||
token = _create_resource_token(harness, app_ids=app_ids)
|
||||
|
||||
response = harness.get(
|
||||
authorization=f"Bearer {token.token}",
|
||||
requested_app_id=harness.target.id if multiple_apps else None,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json == {"result": "success", "data": []}
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
assert len(harness.provisions) == len(harness.queries) == 1
|
||||
harness.assert_closed()
|
||||
with harness.factory() as session:
|
||||
stored_token = session.get(ResourceAccessToken, token.token_id)
|
||||
assert stored_token is not None
|
||||
assert stored_token.last_used_at is not None
|
||||
assert session.scalars(select(EndUser.id)).all() == [harness.end_user.id]
|
||||
|
||||
|
||||
def test_multiple_app_resource_token_requires_app_header_before_provisioning(harness: _Harness) -> None:
|
||||
other = make_app(app_id=str(uuid4()), tenant_id=harness.tenant.id)
|
||||
with harness.factory.begin() as session:
|
||||
session.add(other)
|
||||
token = _create_resource_token(harness, app_ids=(harness.target.id, other.id))
|
||||
|
||||
response = harness.get(authorization=f"Bearer {token.token}", user="new-user")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json == {
|
||||
"code": "bad_request",
|
||||
"message": "App ID is required when a resource access token is bound to multiple apps.",
|
||||
"status": 400,
|
||||
}
|
||||
assert harness.provisions == harness.queries == []
|
||||
harness.assert_closed()
|
||||
with harness.factory() as session:
|
||||
stored_token = session.get(ResourceAccessToken, token.token_id)
|
||||
assert stored_token is not None
|
||||
assert stored_token.last_used_at is None
|
||||
assert session.scalars(select(EndUser.id)).all() == [harness.end_user.id]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure", "status", "code", "message"),
|
||||
[
|
||||
("unbound_app", 403, "forbidden", "Resource access token is not allowed to access this app."),
|
||||
("foreign_app", 403, "forbidden", "Resource access token is not allowed to access this app."),
|
||||
("no_bindings", 403, "forbidden", "Resource access token is not allowed to access this app."),
|
||||
("foreign_binding", 403, "forbidden", "The app no longer exists."),
|
||||
("deleted_app", 403, "forbidden", "The app no longer exists."),
|
||||
("invalid_token", 401, "unauthorized", "Resource access token is invalid."),
|
||||
],
|
||||
)
|
||||
def test_resource_token_denials_stop_before_end_user_provisioning(
|
||||
harness: _Harness, failure: str, status: int, code: str, message: str
|
||||
) -> None:
|
||||
token = _create_resource_token(harness, app_ids=(harness.target.id,))
|
||||
requested_app_id = None
|
||||
authorization = f"Bearer {token.token}"
|
||||
with harness.factory.begin() as session:
|
||||
if failure in {"unbound_app", "foreign_app", "foreign_binding"}:
|
||||
tenant_id = harness.tenant.id
|
||||
if failure != "unbound_app":
|
||||
foreign_tenant = Tenant(name="Other resource owner")
|
||||
session.add(foreign_tenant)
|
||||
tenant_id = foreign_tenant.id
|
||||
other = make_app(app_id=str(uuid4()), tenant_id=tenant_id)
|
||||
session.add(other)
|
||||
if failure == "foreign_binding":
|
||||
relation = session.scalar(
|
||||
select(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token.token_id)
|
||||
)
|
||||
assert relation is not None
|
||||
relation.app_id = other.id
|
||||
else:
|
||||
requested_app_id = other.id
|
||||
elif failure == "no_bindings":
|
||||
session.execute(
|
||||
delete(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token.token_id)
|
||||
)
|
||||
elif failure == "deleted_app":
|
||||
session.execute(delete(App).where(App.id == harness.target.id))
|
||||
else:
|
||||
authorization = f"Bearer sk-{uuid4()}"
|
||||
|
||||
response = harness.get(authorization=authorization, requested_app_id=requested_app_id, user="new-user")
|
||||
|
||||
assert response.status_code == status
|
||||
assert response.json == {"code": code, "message": message, "status": status}
|
||||
assert harness.provisions == harness.queries == []
|
||||
harness.assert_closed()
|
||||
with harness.factory() as session:
|
||||
stored_token = session.get(ResourceAccessToken, token.token_id)
|
||||
assert stored_token is not None
|
||||
assert stored_token.last_used_at is None
|
||||
assert session.scalars(select(EndUser.id)).all() == [harness.end_user.id]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure", "code", "message"),
|
||||
[
|
||||
("disabled_api", "app_api_disabled", "The app's API service has been disabled."),
|
||||
("archived_tenant", "workspace_archived", "The workspace's status is archived."),
|
||||
],
|
||||
)
|
||||
def test_resource_token_keeps_app_and_workspace_policy_errors(
|
||||
harness: _Harness, failure: str, code: str, message: str
|
||||
) -> None:
|
||||
token = _create_resource_token(harness, app_ids=(harness.target.id,))
|
||||
with harness.factory.begin() as session:
|
||||
if failure == "disabled_api":
|
||||
target = session.get(App, harness.target.id)
|
||||
assert target is not None
|
||||
target.enable_api = False
|
||||
else:
|
||||
tenant = session.get(Tenant, harness.tenant.id)
|
||||
assert tenant is not None
|
||||
tenant.status = TenantStatus.ARCHIVE
|
||||
|
||||
response = harness.get(authorization=f"Bearer {token.token}", user="new-user")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json == {"code": code, "message": message, "status": 403}
|
||||
assert harness.provisions == harness.queries == []
|
||||
harness.assert_closed()
|
||||
with harness.factory() as session:
|
||||
assert session.scalars(select(EndUser.id)).all() == [harness.end_user.id]
|
||||
|
||||
|
||||
def test_explicit_service_api_app_tenant_scope_excludes_foreign_apps(harness: _Harness) -> None:
|
||||
services = harness.app.extensions["application_services"]
|
||||
assert isinstance(services, _Services)
|
||||
record = services.app_definitions.get_service_api_app(harness.target.id, tenant_id=harness.tenant.id)
|
||||
assert (record.app_id, record.tenant_id) == (harness.target.id, harness.tenant.id)
|
||||
with pytest.raises(AppDefinitionUnavailableError):
|
||||
services.app_definitions.get_service_api_app(harness.target.id, tenant_id=str(uuid4()))
|
||||
assert services.app_definitions.get_service_api_app(harness.target.id) == record
|
||||
assert harness.provisions == harness.queries == []
|
||||
harness.assert_closed()
|
||||
@@ -0,0 +1,430 @@
|
||||
"""Web suggested questions through HTTP, real admission, and SQLite ownership queries."""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from http import HTTPStatus
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Connection, Engine, delete, event, select, update
|
||||
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
||||
from werkzeug.test import TestResponse
|
||||
|
||||
import controllers.web.message as message_controller
|
||||
from constants import HEADER_NAME_APP_CODE, HEADER_NAME_PASSPORT
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from libs.external_api import ExternalApi
|
||||
from libs.passport import PassportService
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import ConversationFromSource, CustomizeTokenStrategy
|
||||
from models.model import App, AppMode, AppModelConfig, Conversation, EndUser, Message, Site
|
||||
from models.provider import Provider, TenantDefaultModel
|
||||
from models.workflow import Workflow
|
||||
from repositories.message_repository import MessageRepository
|
||||
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
|
||||
from services.message_suggested_questions_queries import SuggestedQuestionsQuery
|
||||
from services.message_suggested_questions_service import (
|
||||
MessageSuggestedQuestions,
|
||||
MessageSuggestedQuestionsService,
|
||||
)
|
||||
from tests.helpers.unit.model_factories import make_app, make_conversation, make_end_user, make_message
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Services:
|
||||
message_suggested_questions: MessageSuggestedQuestions
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Harness:
|
||||
app: Flask
|
||||
target: App
|
||||
end_user: EndUser
|
||||
message: Message
|
||||
factory: sessionmaker[Session]
|
||||
admission_factory: sessionmaker[Session]
|
||||
passport: str
|
||||
sessions: list[Session]
|
||||
|
||||
def get(self) -> TestResponse:
|
||||
return self.app.test_client().get(
|
||||
f"/messages/{self.message.id}/suggested-questions",
|
||||
headers={HEADER_NAME_APP_CODE: "public-app", HEADER_NAME_PASSPORT: self.passport},
|
||||
)
|
||||
|
||||
def assert_closed(self, *, caller: Session | None = None) -> None:
|
||||
assert self.sessions
|
||||
assert all(
|
||||
not session.in_transaction() and not session.identity_map
|
||||
for session in self.sessions
|
||||
if session is not caller
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(
|
||||
_empty_model_provider_daemon: None,
|
||||
config_overrides: Callable[..., None],
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> Iterator[_Harness]:
|
||||
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, SECRET_KEY="suggested-questions-test-secret-key")
|
||||
target = make_app(app_id=str(uuid4()), tenant_id=str(uuid4()))
|
||||
end_user = make_end_user(end_user_id=str(uuid4()), tenant_id=target.tenant_id, app_id=target.id)
|
||||
config = AppModelConfig(
|
||||
app_id=target.id,
|
||||
suggested_questions_after_answer=json.dumps({"enabled": True, "prompt": "Ask concise questions"}),
|
||||
)
|
||||
site = Site(
|
||||
app_id=target.id,
|
||||
title="Public app",
|
||||
default_language="en-US",
|
||||
customize_token_strategy=CustomizeTokenStrategy.UUID,
|
||||
code="public-app",
|
||||
)
|
||||
conversation = make_conversation(
|
||||
conversation_id=str(uuid4()),
|
||||
app_id=target.id,
|
||||
from_source=ConversationFromSource.API,
|
||||
from_end_user_id=end_user.id,
|
||||
inputs={},
|
||||
)
|
||||
message = make_message(
|
||||
message_id=str(uuid4()),
|
||||
app_id=target.id,
|
||||
conversation_id=conversation.id,
|
||||
inputs={},
|
||||
message={},
|
||||
query="How does it work?",
|
||||
answer="Like this.",
|
||||
message_unit_price=Decimal(0),
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
from_end_user_id=end_user.id,
|
||||
)
|
||||
with sqlite_session_factory.begin() as session:
|
||||
# The real provider lookup rejects this stored ID before plugin discovery.
|
||||
# Generation remains best-effort and must return an empty list.
|
||||
provider = Provider(tenant_id=target.tenant_id, provider_name="invalid/provider", is_valid=True)
|
||||
session.add(
|
||||
TenantDefaultModel(
|
||||
tenant_id=target.tenant_id,
|
||||
provider_name="invalid/provider",
|
||||
model_name="invalid-model",
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
)
|
||||
session.add_all([target, end_user, config, site, conversation, message, provider])
|
||||
session.flush()
|
||||
target.app_model_config_id = config.id
|
||||
conversation.app_model_config_id = config.id
|
||||
|
||||
read_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
admission_factory = sqlite_session_factory
|
||||
sessions: list[Session] = []
|
||||
|
||||
def track_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
sessions.append(session)
|
||||
|
||||
admission_sessions: list[Session] = []
|
||||
|
||||
def track_admission(session: Session, transaction: SessionTransaction, connection: Connection) -> None:
|
||||
admission_sessions.append(session)
|
||||
track_session(session, transaction, connection)
|
||||
|
||||
event.listen(admission_factory, "after_begin", track_admission)
|
||||
event.listen(read_factory, "after_begin", track_session)
|
||||
passport = PassportService().issue({"app_code": site.code, "app_id": target.id, "end_user_id": end_user.id})
|
||||
app = Flask(__name__)
|
||||
app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
|
||||
db.init_app(app)
|
||||
event.listen(db.session.session_factory, "after_begin", track_session)
|
||||
queries = SuggestedQuestionsQuery(session_factory=read_factory, repository_factory=MessageRepository)
|
||||
services = _Services(MessageSuggestedQuestionsService(queries=queries, generator=SuggestedQuestionsGenerator()))
|
||||
app.extensions["application_services"] = services
|
||||
api = ExternalApi(app)
|
||||
api.add_resource(message_controller.MessageSuggestedQuestionApi, "/messages/<uuid:message_id>/suggested-questions")
|
||||
yield _Harness(
|
||||
app,
|
||||
target,
|
||||
end_user,
|
||||
message,
|
||||
sqlite_session_factory,
|
||||
admission_factory,
|
||||
passport,
|
||||
sessions,
|
||||
)
|
||||
event.remove(admission_factory, "after_begin", track_admission)
|
||||
event.remove(read_factory, "after_begin", track_session)
|
||||
event.remove(db.session.session_factory, "after_begin", track_session)
|
||||
with app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
def test_chat_modes_return_empty_questions_when_stored_provider_config_is_invalid(
|
||||
harness: _Harness,
|
||||
mode: AppMode,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
|
||||
if mode == AppMode.ADVANCED_CHAT:
|
||||
workflow = Workflow.new(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
app_id=harness.target.id,
|
||||
type="chat",
|
||||
version="published",
|
||||
graph="{}",
|
||||
features='{"suggested_questions_after_answer":{"enabled":true}}',
|
||||
created_by=str(uuid4()),
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
session.add(workflow)
|
||||
session.flush()
|
||||
app_model = session.get(App, harness.target.id)
|
||||
assert app_model is not None
|
||||
app_model.workflow_id = workflow.id
|
||||
elif mode == AppMode.AGENT:
|
||||
agent = Agent(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
app_id=harness.target.id,
|
||||
name="Agent",
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
)
|
||||
session.add(agent)
|
||||
session.flush()
|
||||
snapshot = AgentConfigSnapshot(
|
||||
tenant_id=agent.tenant_id,
|
||||
agent_id=agent.id,
|
||||
version=1,
|
||||
config_snapshot=AgentSoulConfig.model_validate(
|
||||
{"app_features": {"suggested_questions_after_answer": {"enabled": True}}}
|
||||
),
|
||||
)
|
||||
session.add(snapshot)
|
||||
session.flush()
|
||||
agent.active_config_snapshot_id = snapshot.id
|
||||
session.add(
|
||||
AgentConfigRevision(
|
||||
tenant_id=agent.tenant_id,
|
||||
agent_id=agent.id,
|
||||
current_snapshot_id=snapshot.id,
|
||||
revision=1,
|
||||
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
)
|
||||
)
|
||||
response = harness.get()
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
assert response.get_json() == {"data": []}
|
||||
failures = [
|
||||
record.exc_info[1]
|
||||
for record in caplog.records
|
||||
if record.name == "services.message_suggested_questions_generator" and record.exc_info is not None
|
||||
]
|
||||
assert len(failures) == 1
|
||||
assert isinstance(failures[0], ValueError)
|
||||
assert str(failures[0]) == "Invalid provider: invalid/provider"
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.COMPLETION, AppMode.WORKFLOW])
|
||||
def test_non_chat_app_is_rejected_before_shared_capability(harness: _Harness, mode: AppMode) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
|
||||
if mode == AppMode.WORKFLOW:
|
||||
workflow = Workflow.new(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
app_id=harness.target.id,
|
||||
type="workflow",
|
||||
version="published",
|
||||
graph="{}",
|
||||
features="{}",
|
||||
created_by=str(uuid4()),
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
session.add(workflow)
|
||||
session.flush()
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(workflow_id=workflow.id))
|
||||
response = harness.get()
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.get_json()["code"] == "not_chat_app"
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("resource", "status", "code", "message"),
|
||||
[
|
||||
("agent_version", 404, "app_not_found", "App not found"),
|
||||
("message", 404, "not_found", "Message not found"),
|
||||
("conversation", 404, "not_found", "Conversation not found"),
|
||||
("disabled", 403, "app_suggested_questions_after_answer_disabled", "disabled"),
|
||||
],
|
||||
)
|
||||
def test_resource_errors_keep_specific_http_codes(
|
||||
harness: _Harness,
|
||||
resource: Literal["agent_version", "message", "conversation", "disabled"],
|
||||
status: int,
|
||||
code: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
if resource == "message":
|
||||
session.execute(delete(Message).where(Message.id == harness.message.id))
|
||||
elif resource == "conversation":
|
||||
session.execute(delete(Conversation).where(Conversation.id == harness.message.conversation_id))
|
||||
elif resource == "disabled":
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.app_id == harness.target.id)
|
||||
.values(suggested_questions_after_answer='{"enabled":false}')
|
||||
)
|
||||
else:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.AGENT))
|
||||
session.add(
|
||||
Agent(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
app_id=harness.target.id,
|
||||
name="Broken agent",
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id=str(uuid4()),
|
||||
)
|
||||
)
|
||||
response = harness.get()
|
||||
assert response.status_code == status
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
body = response.get_json()
|
||||
assert body["code"] == code
|
||||
assert body["status"] == status
|
||||
assert message in body["message"]
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("resource", "status", "code", "message"),
|
||||
[
|
||||
("end_user", 404, "not_found", "End user not found"),
|
||||
(
|
||||
"app_mode",
|
||||
400,
|
||||
"app_unavailable",
|
||||
"App unavailable, please check your app configurations.",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_resource_changes_after_admission_keep_specific_errors(
|
||||
harness: _Harness,
|
||||
resource: Literal["end_user", "app_mode"],
|
||||
status: int,
|
||||
code: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
# Change persisted state after the real admission transaction ends; the
|
||||
# application service and repository must reject the now-stale reference.
|
||||
def change_resource(_session: Session, _transaction: SessionTransaction) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
if resource == "end_user":
|
||||
session.execute(delete(EndUser).where(EndUser.id == harness.end_user.id))
|
||||
else:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.WORKFLOW))
|
||||
|
||||
event.listen(harness.admission_factory, "after_transaction_end", change_resource, once=True)
|
||||
try:
|
||||
response = harness.get()
|
||||
finally:
|
||||
event.remove(harness.admission_factory, "after_transaction_end", change_resource)
|
||||
assert response.status_code == status
|
||||
assert response.get_json()["code"] == code
|
||||
assert response.get_json()["message"] == message
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
def test_model_resolution_failure_preserves_caller_session_and_pending_edits(harness: _Harness) -> None:
|
||||
with harness.app.app_context():
|
||||
caller = db.session()
|
||||
app_model = caller.get(App, harness.target.id)
|
||||
assert app_model is not None
|
||||
app_model.name = "Uncommitted caller change"
|
||||
response = harness.get()
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.get_json() == {"data": []}
|
||||
assert db.session() is caller
|
||||
assert caller.in_transaction()
|
||||
assert app_model in caller.dirty
|
||||
harness.assert_closed(caller=caller)
|
||||
with harness.factory() as session:
|
||||
persisted = session.get(App, harness.target.id)
|
||||
assert persisted is not None
|
||||
assert persisted.name == harness.target.name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field_name", ["tenant_id", "app_id"])
|
||||
def test_admission_rejects_end_user_from_another_owner(
|
||||
harness: _Harness,
|
||||
field_name: Literal["tenant_id", "app_id"],
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(EndUser).where(EndUser.id == harness.end_user.id).values({field_name: str(uuid4())}))
|
||||
response = harness.get()
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
# Passport admission rejects the owner mismatch before the message service.
|
||||
assert response.get_json()["code"] == "not_found"
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", [Message, Conversation])
|
||||
@pytest.mark.parametrize("field_name", ["app_id", "from_end_user_id", "from_source"])
|
||||
def test_real_runtime_rejects_message_or_conversation_outside_actor_scope(
|
||||
harness: _Harness,
|
||||
model: type[Message] | type[Conversation],
|
||||
field_name: Literal["app_id", "from_end_user_id", "from_source"],
|
||||
) -> None:
|
||||
record_id = harness.message.id if model is Message else harness.message.conversation_id
|
||||
value = ConversationFromSource.CONSOLE if field_name == "from_source" else str(uuid4())
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(model).where(model.id == record_id).values({field_name: value}))
|
||||
response = harness.get()
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["message"] == ("Message not found" if model is Message else "Conversation not found")
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
def test_missing_saved_default_returns_empty_questions_without_creating_a_default(harness: _Harness) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(delete(TenantDefaultModel).where(TenantDefaultModel.tenant_id == harness.target.tenant_id))
|
||||
|
||||
response = harness.get()
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.get_json() == {"data": []}
|
||||
with harness.factory() as session:
|
||||
assert (
|
||||
session.scalar(
|
||||
select(TenantDefaultModel.id).where(TenantDefaultModel.tenant_id == harness.target.tenant_id)
|
||||
)
|
||||
is None
|
||||
)
|
||||
harness.assert_closed()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Opt-in HTTP fixtures for provider discovery without a deployed plugin daemon."""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from threading import Thread
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.serving import make_server
|
||||
from yarl import URL
|
||||
|
||||
from core.plugin.impl import base as plugin_base
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def _empty_model_provider_daemon_url() -> Iterator[URL]:
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.get("/plugin/<tenant_id>/management/models")
|
||||
def list_model_providers(tenant_id: str) -> dict[str, object]:
|
||||
return {"code": 0, "message": "success", "data": []}
|
||||
|
||||
server = make_server("127.0.0.1", 0, app, threaded=True)
|
||||
thread = Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield URL(f"http://127.0.0.1:{server.server_port}")
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
server.server_close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _empty_model_provider_daemon(
|
||||
_empty_model_provider_daemon_url: URL,
|
||||
config_overrides: Callable[..., None],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Run real model discovery against an empty installation over loopback HTTP.
|
||||
|
||||
Skip the global unit-test Redis mock so a cache response cannot determine
|
||||
whether malformed stored providers reach their intended resolution error.
|
||||
"""
|
||||
config_overrides(PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED=False)
|
||||
monkeypatch.setattr(plugin_base, "plugin_daemon_inner_api_baseurl", _empty_model_provider_daemon_url)
|
||||
@@ -39,7 +39,11 @@ PROJECT_DIR = os.path.abspath(os.path.join(ABS_PATH, os.pardir, os.pardir))
|
||||
|
||||
CACHED_APP = Flask(__name__)
|
||||
|
||||
pytest_plugins = ("tests.helpers.unit.audio_runtime_fixtures", "tests.helpers.unit.agent_runtime_fixtures")
|
||||
pytest_plugins = (
|
||||
"tests.helpers.unit.audio_runtime_fixtures",
|
||||
"tests.helpers.unit.agent_runtime_fixtures",
|
||||
"tests.helpers.unit.model_provider_fixtures",
|
||||
)
|
||||
|
||||
# set global mock for Redis client
|
||||
redis_mock = MagicMock()
|
||||
|
||||
@@ -1,81 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from http import HTTPStatus
|
||||
from inspect import unwrap
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Connection, Engine, event, func, select, text, update
|
||||
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
||||
from werkzeug.test import TestResponse
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import controllers.console.wraps as console_wraps
|
||||
from controllers.common.rbac import RBACPermission, RBACResourceScope
|
||||
from controllers.common.rbac.checks import RBACService
|
||||
from controllers.console.app import message as controller
|
||||
from controllers.console.app import message as message_module
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from core.model_manager import ModelInstance
|
||||
from core.ops.ops_trace_manager import TraceTask
|
||||
from extensions.application_services.app import AppServices
|
||||
from extensions.application_services.rbac import RBACServices
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
|
||||
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs import login
|
||||
from libs.external_api import ExternalApi
|
||||
from models.account import Account, Tenant
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigVersionKind,
|
||||
AgentDebugConversation,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
AgentWorkspaceBinding,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.account import Account
|
||||
from models.enums import ConversationFromSource, CreatorUserRole, FeedbackFromSource, FeedbackRating
|
||||
from models.model import (
|
||||
App,
|
||||
AppAnnotationHitHistory,
|
||||
AppMode,
|
||||
AppModelConfig,
|
||||
Conversation,
|
||||
Message,
|
||||
MessageAgentThought,
|
||||
MessageAnnotation,
|
||||
MessageFeedback,
|
||||
)
|
||||
from repositories.app.agent_app_repository import AgentAppRepository
|
||||
from services import message_service
|
||||
from services.app.agent_app_service import AgentAppAccessService
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
|
||||
from services.message_suggested_questions_adapters import MessageSuggestedQuestionsRuntime
|
||||
from services.message_suggested_questions_service import (
|
||||
MessageSuggestedQuestions,
|
||||
SuggestedQuestionsAccount,
|
||||
SuggestedQuestionsActor,
|
||||
SuggestedQuestionsActorNotFoundError,
|
||||
)
|
||||
from tests.helpers.unit.config_override import apply_config_overrides
|
||||
from tests.helpers.unit.model_factories import make_account, make_app, make_conversation, make_message
|
||||
from tests.helpers.unit.rbac_fakes import RBACDomain
|
||||
|
||||
|
||||
def _account() -> Account:
|
||||
@@ -358,13 +305,6 @@ class TestMessageApiCases:
|
||||
response = message_module.AnnotationCountResponse(count=10)
|
||||
assert response.count == 10
|
||||
|
||||
@staticmethod
|
||||
def test_suggested_questions_response(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test SuggestedQuestionsResponse creation."""
|
||||
response = message_module.SuggestedQuestionsResponse(data=["What is AI?", "How does ML work?"])
|
||||
assert len(response.data) == 2
|
||||
assert response.data[0] == "What is AI?"
|
||||
|
||||
@staticmethod
|
||||
def test_message_detail_response_normalizes_aliases_and_timestamp(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -432,514 +372,3 @@ class TestMessageApiCases:
|
||||
"parent_message_id": None,
|
||||
"extra_contents": [],
|
||||
}
|
||||
|
||||
|
||||
"""Console suggested-question admission and actor/configuration isolation over SQLite."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Questions:
|
||||
questions: list[str] = field(default_factory=lambda: ["Next?"])
|
||||
failure: Exception | None = None
|
||||
calls: list[tuple[str, str, str, SuggestedQuestionsActor, str]] = field(default_factory=list)
|
||||
|
||||
def get_suggested_questions(
|
||||
self,
|
||||
*,
|
||||
app_id: str,
|
||||
app_owner_tenant_id: str,
|
||||
expected_app_mode: str,
|
||||
actor: SuggestedQuestionsActor,
|
||||
message_id: str,
|
||||
) -> list[str]:
|
||||
self.calls.append((app_id, app_owner_tenant_id, expected_app_mode, actor, message_id))
|
||||
if self.failure is not None:
|
||||
raise self.failure
|
||||
return self.questions
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Agents:
|
||||
access: AgentAppAccessService
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Services:
|
||||
apps: AppServices
|
||||
agent_apps: _Agents
|
||||
message_suggested_questions: MessageSuggestedQuestions
|
||||
rbac: RBACServices
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Provider:
|
||||
tenant_id: str
|
||||
admission_sessions: list[Session]
|
||||
prompts: list[str] = field(default_factory=list)
|
||||
traces: list[TraceTask] = field(default_factory=list)
|
||||
|
||||
def get_default_model_instance(self, *, tenant_id: str, model_type: ModelType) -> ModelInstance:
|
||||
assert tenant_id == self.tenant_id
|
||||
assert model_type == ModelType.LLM
|
||||
assert self.admission_sessions
|
||||
assert all(not session.in_transaction() and not session.identity_map for session in self.admission_sessions)
|
||||
return cast(ModelInstance, self)
|
||||
|
||||
def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int:
|
||||
return len(prompt_messages)
|
||||
|
||||
def get_model_schema(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def invoke_llm(
|
||||
self,
|
||||
*,
|
||||
prompt_messages: list[PromptMessage],
|
||||
model_parameters: dict[str, object],
|
||||
stop: list[str],
|
||||
stream: bool,
|
||||
) -> LLMResult:
|
||||
assert model_parameters == {"max_tokens": 256, "temperature": 0.0}
|
||||
assert stop == []
|
||||
assert stream is False
|
||||
self.prompts.append(prompt_messages[0].get_text_content())
|
||||
return LLMResult(
|
||||
model="test-model", message=AssistantPromptMessage(content='["Next?"]'), usage=LLMUsage.empty_usage()
|
||||
)
|
||||
|
||||
def add_trace_task(self, task: TraceTask) -> None:
|
||||
self.traces.append(task)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Harness:
|
||||
flask_app: Flask
|
||||
target: App
|
||||
account: Account
|
||||
conversation: Conversation
|
||||
message: Message
|
||||
agent_id: str
|
||||
factory: sessionmaker[Session]
|
||||
services: _Services
|
||||
provider: _Provider
|
||||
permission_calls: list[tuple[str, str, RBACPermission, RBACResourceScope | None, str | None]]
|
||||
allowed: bool = True
|
||||
|
||||
def get(self, route: str = "app") -> TestResponse:
|
||||
resource = f"apps/{self.target.id}" if route == "app" else f"agent/{self.agent_id}"
|
||||
return self.flask_app.test_client().get(f"/{resource}/chat-messages/{self.message.id}/suggested-questions")
|
||||
|
||||
def add_agent(self, *, scope: AgentScope = AgentScope.ROSTER, backing: bool = True) -> None:
|
||||
with self.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == self.target.id).values(mode=AppMode.AGENT))
|
||||
session.add(
|
||||
Agent(
|
||||
id=self.agent_id,
|
||||
tenant_id=self.target.tenant_id,
|
||||
name="Agent",
|
||||
scope=scope,
|
||||
source=AgentSource.AGENT_APP if scope == AgentScope.ROSTER else AgentSource.WORKFLOW,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id=self.target.id if scope == AgentScope.ROSTER else str(uuid4()),
|
||||
backing_app_id=self.target.id if scope == AgentScope.WORKFLOW_ONLY and backing else None,
|
||||
workflow_id=str(uuid4()) if scope == AgentScope.WORKFLOW_ONLY else None,
|
||||
workflow_node_id="node" if scope == AgentScope.WORKFLOW_ONLY else None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestMessageSuggestedQuestionsCases:
|
||||
@staticmethod
|
||||
@pytest.fixture
|
||||
def harness(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
app_services: AppServices,
|
||||
rbac_domain: RBACDomain,
|
||||
) -> Iterator[_Harness]:
|
||||
target = make_app(app_id=str(uuid4()), tenant_id=str(uuid4()))
|
||||
account = make_account(account_id=str(uuid4()))
|
||||
tenant = Tenant(name="Workspace")
|
||||
tenant.id = target.tenant_id
|
||||
account._current_tenant = tenant
|
||||
conversation = make_conversation(
|
||||
conversation_id=str(uuid4()),
|
||||
app_id=target.id,
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
inputs={},
|
||||
)
|
||||
config = AppModelConfig(
|
||||
app_id=target.id,
|
||||
suggested_questions_after_answer=json.dumps({"enabled": True, "prompt": "Console questions"}),
|
||||
)
|
||||
message = make_message(
|
||||
message_id=str(uuid4()),
|
||||
app_id=target.id,
|
||||
conversation_id=conversation.id,
|
||||
inputs={},
|
||||
message={},
|
||||
query="How?",
|
||||
answer="Like this.",
|
||||
message_unit_price=Decimal(0),
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
)
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add_all([target, account, conversation, config, message])
|
||||
session.flush()
|
||||
target.app_model_config_id = config.id
|
||||
conversation.app_model_config_id = config.id
|
||||
sessions: list[Session] = []
|
||||
|
||||
def track_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
sessions.append(session)
|
||||
|
||||
event.listen(sqlite_session_factory, "after_begin", track_session)
|
||||
provider = _Provider(target.tenant_id, sessions)
|
||||
|
||||
def manager(*, tenant_id: str) -> _Provider:
|
||||
assert tenant_id == target.tenant_id
|
||||
return provider
|
||||
|
||||
def trace_manager(*, app_id: str) -> _Provider:
|
||||
assert app_id == target.id
|
||||
return provider
|
||||
|
||||
monkeypatch.setattr(message_service.ModelManager, "for_tenant", manager)
|
||||
monkeypatch.setattr(message_service, "TraceQueueManager", trace_manager)
|
||||
monkeypatch.setattr(login, "current_user", account)
|
||||
monkeypatch.setattr(console_wraps, "_is_setup_completed", lambda: True)
|
||||
apply_config_overrides(monkeypatch, LOGIN_DISABLED=True, RBAC_ENABLED=False)
|
||||
flask_app = Flask(__name__)
|
||||
flask_app.config.update(
|
||||
TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url)
|
||||
)
|
||||
db.init_app(flask_app)
|
||||
services = _Services(
|
||||
app_services,
|
||||
_Agents(AgentAppAccessService(references=AgentAppRepository(session_factory=sqlite_session_factory))),
|
||||
MessageSuggestedQuestionsRuntime(session_factory=sqlite_session_factory),
|
||||
rbac_domain.rbac,
|
||||
)
|
||||
flask_app.extensions["application_services"] = services
|
||||
api = ExternalApi(flask_app)
|
||||
api.add_resource(
|
||||
controller.MessageSuggestedQuestionApi,
|
||||
"/apps/<uuid:app_id>/chat-messages/<uuid:message_id>/suggested-questions",
|
||||
)
|
||||
api.add_resource(
|
||||
controller.AgentMessageSuggestedQuestionApi,
|
||||
"/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions",
|
||||
)
|
||||
result = _Harness(
|
||||
flask_app,
|
||||
target,
|
||||
account,
|
||||
conversation,
|
||||
message,
|
||||
str(uuid4()),
|
||||
sqlite_session_factory,
|
||||
services,
|
||||
provider,
|
||||
[],
|
||||
)
|
||||
|
||||
def permission(
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
*,
|
||||
scene: RBACPermission,
|
||||
resource_type: RBACResourceScope | None,
|
||||
resource_id: str | None,
|
||||
) -> bool:
|
||||
result.permission_calls.append((tenant_id, account_id, scene, resource_type, resource_id))
|
||||
return result.allowed
|
||||
|
||||
monkeypatch.setattr(RBACService.CheckAccess, "check", permission)
|
||||
yield result
|
||||
event.remove(sqlite_session_factory, "after_begin", track_session)
|
||||
with flask_app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@pytest.mark.parametrize("questions", [[], ["Next?", "More?"]])
|
||||
def test_app_reference_and_account_are_passed_to_shared_runtime(
|
||||
harness: _Harness, mode: AppMode, questions: list[str]
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
|
||||
port = _Questions(questions=questions)
|
||||
harness.services.message_suggested_questions = port
|
||||
response = harness.get()
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
assert response.get_json() == {"data": questions}
|
||||
assert port.calls == [
|
||||
(
|
||||
harness.target.id,
|
||||
harness.target.tenant_id,
|
||||
mode,
|
||||
SuggestedQuestionsAccount(harness.account.id, "debugger"),
|
||||
harness.message.id,
|
||||
)
|
||||
]
|
||||
assert all(
|
||||
not session.in_transaction() and not session.identity_map for session in harness.provider.admission_sessions
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize("change", ["tenant", "status", "missing", "workflow", "completion", "hidden"])
|
||||
def test_app_admission_rejects_unavailable_or_unsupported_apps(harness: _Harness, change: str) -> None:
|
||||
if change == "hidden":
|
||||
harness.add_agent(scope=AgentScope.WORKFLOW_ONLY)
|
||||
else:
|
||||
field_name, value = {
|
||||
"tenant": ("tenant_id", str(uuid4())),
|
||||
"status": ("status", "archived"),
|
||||
"missing": ("id", str(uuid4())),
|
||||
"workflow": ("mode", "workflow"),
|
||||
"completion": ("mode", "completion"),
|
||||
}[change]
|
||||
with harness.factory.begin() as session:
|
||||
if change == "status":
|
||||
session.execute(
|
||||
text("UPDATE apps SET status = :status WHERE id = :id"),
|
||||
{"status": value, "id": harness.target.id},
|
||||
)
|
||||
else:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values({field_name: value}))
|
||||
port = _Questions()
|
||||
harness.services.message_suggested_questions = port
|
||||
response = harness.get()
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["code"] == "app_not_found"
|
||||
assert not port.calls
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize("scope", [AgentScope.ROSTER, AgentScope.WORKFLOW_ONLY])
|
||||
def test_agent_resolves_existing_backing_app(harness: _Harness, scope: AgentScope) -> None:
|
||||
harness.add_agent(scope=scope)
|
||||
port = _Questions()
|
||||
harness.services.message_suggested_questions = port
|
||||
response = harness.get("agent")
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert port.calls == [
|
||||
(
|
||||
harness.target.id,
|
||||
harness.target.tenant_id,
|
||||
AppMode.AGENT,
|
||||
SuggestedQuestionsAccount(harness.account.id, "debugger"),
|
||||
harness.message.id,
|
||||
)
|
||||
]
|
||||
assert all(
|
||||
not session.in_transaction() and not session.identity_map for session in harness.provider.admission_sessions
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize("change", ["missing_backing", "tenant", "agent_status", "app_status", "app_mode"])
|
||||
def test_agent_rejects_unavailable_runtime_without_creating_app(harness: _Harness, change: str) -> None:
|
||||
harness.add_agent(scope=AgentScope.WORKFLOW_ONLY, backing=change != "missing_backing")
|
||||
if change != "missing_backing":
|
||||
model, key, value = {
|
||||
"tenant": (Agent, "tenant_id", str(uuid4())),
|
||||
"agent_status": (Agent, "status", AgentStatus.ARCHIVED),
|
||||
"app_status": (App, "status", "archived"),
|
||||
"app_mode": (App, "mode", AppMode.CHAT),
|
||||
}[change]
|
||||
with harness.factory.begin() as session:
|
||||
if change == "app_status":
|
||||
session.execute(
|
||||
text("UPDATE apps SET status = :status WHERE id = :id"),
|
||||
{"status": value, "id": harness.target.id},
|
||||
)
|
||||
else:
|
||||
session.execute(update(model).values({key: value}))
|
||||
response = harness.get("agent")
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["code"] == "agent_not_found_error"
|
||||
with harness.factory() as session:
|
||||
assert session.scalar(select(func.count()).select_from(App)) == 1
|
||||
assert session.scalar(select(func.count()).select_from(AppModelConfig)) == 1
|
||||
if change == "missing_backing":
|
||||
assert session.scalar(select(Agent.backing_app_id)) is None
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize("route", ["app", "agent"])
|
||||
@pytest.mark.parametrize("allowed", [True, False])
|
||||
def test_real_rbac_admission_keeps_resource_scene(
|
||||
harness: _Harness, monkeypatch: pytest.MonkeyPatch, route: str, allowed: bool
|
||||
) -> None:
|
||||
apply_config_overrides(monkeypatch, RBAC_ENABLED=True)
|
||||
if route == "agent":
|
||||
harness.add_agent()
|
||||
harness.allowed = allowed
|
||||
port = _Questions()
|
||||
harness.services.message_suggested_questions = port
|
||||
response = harness.get(route)
|
||||
assert response.status_code == (HTTPStatus.OK if allowed else HTTPStatus.FORBIDDEN)
|
||||
assert harness.permission_calls == [
|
||||
(
|
||||
harness.target.tenant_id,
|
||||
harness.account.id,
|
||||
RBACPermission.APP_VIEW_LAYOUT if route == "app" else RBACPermission.AGENT_TEST_AND_RUN,
|
||||
RBACResourceScope.APP if route == "app" else RBACResourceScope.AGENT,
|
||||
harness.target.id if route == "app" else harness.agent_id,
|
||||
)
|
||||
]
|
||||
assert len(port.calls) == int(allowed)
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize(
|
||||
("error", "status", "code"),
|
||||
[
|
||||
(AppDefinitionUnavailableError("stale app"), 400, "app_unavailable"),
|
||||
(SuggestedQuestionsActorNotFoundError("deleted account"), 401, "unauthorized"),
|
||||
(MessageNotExistsError(), 404, "not_found"),
|
||||
(ConversationNotExistsError(), 404, "not_found"),
|
||||
(ProviderTokenNotInitError(), 400, "provider_not_initialize"),
|
||||
(QuotaExceededError(), 400, "provider_quota_exceeded"),
|
||||
(ModelCurrentlyNotSupportError(), 400, "model_currently_not_support"),
|
||||
(InvokeError("provider failed"), 400, "completion_request_error"),
|
||||
(SuggestedQuestionsAfterAnswerDisabledError(), 403, "app_suggested_questions_after_answer_disabled"),
|
||||
(RuntimeError("private credentials"), 500, "internal_server_error"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("route", ["app", "agent"])
|
||||
def test_precise_errors_are_translated_at_http_boundary(
|
||||
harness: _Harness, error: Exception, status: int, code: str, route: str
|
||||
) -> None:
|
||||
if route == "agent":
|
||||
harness.add_agent()
|
||||
harness.services.message_suggested_questions = _Questions(failure=error)
|
||||
response = harness.get(route)
|
||||
assert response.status_code == status
|
||||
assert response.get_json()["code"] == code
|
||||
assert "private credentials" not in response.get_data(as_text=True)
|
||||
|
||||
@staticmethod
|
||||
def test_chat_runtime_uses_console_history_after_reference_session_closed(harness: _Harness) -> None:
|
||||
with harness.flask_app.app_context():
|
||||
caller_session = db.session()
|
||||
caller_app = caller_session.get(App, harness.target.id)
|
||||
assert caller_app is not None
|
||||
caller_app.name = "Pending caller edit"
|
||||
response = harness.get()
|
||||
assert db.session() is caller_session
|
||||
assert caller_session.in_transaction()
|
||||
assert caller_app in caller_session.dirty
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.get_json() == {"data": ["Next?"]}
|
||||
assert len(harness.provider.prompts) == 1
|
||||
assert "Console questions" in harness.provider.prompts[0]
|
||||
assert "How?" in harness.provider.prompts[0]
|
||||
assert len(harness.provider.traces) == 1
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize("entity", [Message, Conversation])
|
||||
@pytest.mark.parametrize("change", ["app_id", "from_account_id", "from_source"])
|
||||
def test_runtime_cannot_read_another_accounts_message_or_conversation(
|
||||
harness: _Harness, entity: type[Message] | type[Conversation], change: str
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(entity).values({change: "api" if change == "from_source" else str(uuid4())}))
|
||||
response = harness.get()
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["message"] == (
|
||||
"Message not found" if entity is Message else "Conversation not found"
|
||||
)
|
||||
assert not harness.provider.prompts
|
||||
|
||||
@staticmethod
|
||||
def test_agent_runtime_uses_current_accounts_personal_debug_draft(harness: _Harness) -> None:
|
||||
harness.add_agent()
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values({App.app_model_config_id: None}))
|
||||
session.add(
|
||||
AgentDebugConversation(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
agent_id=harness.agent_id,
|
||||
app_id=harness.target.id,
|
||||
account_id=harness.account.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
conversation_id=harness.conversation.id,
|
||||
)
|
||||
)
|
||||
for owner, prompt in [
|
||||
(harness.account.id, "My personal draft"),
|
||||
(str(uuid4()), "Other accounts secret draft"),
|
||||
]:
|
||||
session.add(
|
||||
AgentConfigDraft(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
agent_id=harness.agent_id,
|
||||
account_id=owner,
|
||||
draft_owner_key=owner,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
config_snapshot=AgentSoulConfig.model_validate(
|
||||
{"app_features": {"suggested_questions_after_answer": {"enabled": True, "prompt": prompt}}}
|
||||
),
|
||||
)
|
||||
)
|
||||
response = harness.get("agent")
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.get_json() == {"data": ["Next?"]}
|
||||
assert "My personal draft" in harness.provider.prompts[0]
|
||||
assert "Other accounts secret draft" not in harness.provider.prompts[0]
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize("same_tenant", [True, False])
|
||||
def test_agent_runtime_preserves_conversation_binding_scope(harness: _Harness, same_tenant: bool) -> None:
|
||||
harness.add_agent()
|
||||
draft_id, binding_id = str(uuid4()), str(uuid4())
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values({App.app_model_config_id: None}))
|
||||
session.add(
|
||||
AgentConfigDraft(
|
||||
id=draft_id,
|
||||
tenant_id=harness.target.tenant_id,
|
||||
agent_id=harness.agent_id,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
config_snapshot=AgentSoulConfig.model_validate(
|
||||
{
|
||||
"app_features": {
|
||||
"suggested_questions_after_answer": {
|
||||
"enabled": True,
|
||||
"prompt": "Conversation bound questions",
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
AgentWorkspaceBinding(
|
||||
id=binding_id,
|
||||
tenant_id=harness.target.tenant_id if same_tenant else str(uuid4()),
|
||||
app_id=harness.target.id,
|
||||
workspace_id=str(uuid4()),
|
||||
agent_id=harness.agent_id,
|
||||
agent_config_version_id=draft_id,
|
||||
agent_config_version_kind=AgentConfigVersionKind.DRAFT,
|
||||
backend_binding_ref="existing-binding",
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
update(Conversation)
|
||||
.where(Conversation.id == harness.conversation.id)
|
||||
.values({Conversation.agent_workspace_binding_id: binding_id})
|
||||
)
|
||||
response = harness.get("agent")
|
||||
if same_tenant:
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert "Conversation bound questions" in harness.provider.prompts[0]
|
||||
else:
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["code"] == "agent_version_not_found_error"
|
||||
assert not harness.provider.prompts
|
||||
@@ -1,47 +1,33 @@
|
||||
"""Explore message HTTP contracts through real admission, services and SQLite."""
|
||||
|
||||
import json
|
||||
from collections.abc import Generator, Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass, field, replace
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Literal, cast
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import JsonValue
|
||||
from sqlalchemy import Connection, Engine, event, select, update
|
||||
from sqlalchemy import Connection, event, select, update
|
||||
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
||||
from werkzeug.exceptions import Forbidden
|
||||
from werkzeug.test import TestResponse
|
||||
|
||||
import controllers.console.explore.message as module
|
||||
import services.message_service as message_module
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from core.model_manager import ModelInstance
|
||||
from core.ops.ops_trace_manager import TraceTask
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities import AssistantPromptMessage, PromptMessage
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
|
||||
from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelType
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from models import Account, App, AppMode, InstalledApp
|
||||
from models import App, AppMode, InstalledApp
|
||||
from models.enums import ConversationFromSource, ConversationStatus, FeedbackFromSource, FeedbackRating
|
||||
from models.model import AppModelConfig, Conversation, Message, MessageFeedback
|
||||
from models.model import Conversation, Message, MessageFeedback
|
||||
from repositories.installed_app_message_repository import SQLAlchemyInstalledAppMessageRepository
|
||||
from repositories.installed_app_repository import SQLAlchemyInstalledAppRepository
|
||||
from services.errors.app import MoreLikeThisDisabledError
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
|
||||
from services.errors.message import MessageNotExistsError
|
||||
from services.installed_app_generation_service import GenerationResponse, InstalledAppGenerationService
|
||||
from services.installed_app_message_service import InstalledAppMessageService, MessageFeedbackEvent
|
||||
from services.message_suggested_questions_adapters import MessageSuggestedQuestionsRuntime
|
||||
from services.message_suggested_questions_service import (
|
||||
MessageSuggestedQuestions,
|
||||
SuggestedQuestionsAccount,
|
||||
SuggestedQuestionsActor,
|
||||
)
|
||||
from tests.unit_tests.controllers.console.explore.test_installed_app_admission import (
|
||||
_assert_json_response,
|
||||
_Harness,
|
||||
@@ -51,8 +37,8 @@ from tests.unit_tests.controllers.console.explore.test_installed_app_admission i
|
||||
__all__ = ["harness"]
|
||||
|
||||
_CREATED_AT = datetime(2024, 1, 1)
|
||||
type _Operation = Literal["list", "feedback", "more-like-this", "suggested-questions"]
|
||||
_OPERATIONS: tuple[_Operation, ...] = ("list", "feedback", "more-like-this", "suggested-questions")
|
||||
type _Operation = Literal["list", "feedback", "more-like-this"]
|
||||
_OPERATIONS: tuple[_Operation, ...] = ("list", "feedback", "more-like-this")
|
||||
_BLOCKING: dict[str, object] = {"answer": "你好", "metadata": {}, "usage": None}
|
||||
|
||||
|
||||
@@ -65,7 +51,6 @@ class _InstalledAppServices:
|
||||
@dataclass(frozen=True)
|
||||
class _Services:
|
||||
installed_apps: _InstalledAppServices
|
||||
message_suggested_questions: MessageSuggestedQuestions
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -77,8 +62,6 @@ class _Messages:
|
||||
extras: dict[str, list[dict[str, JsonValue]]] = field(default_factory=dict)
|
||||
extra_calls: list[list[str]] = field(default_factory=list)
|
||||
feedback_events: list[MessageFeedbackEvent] = field(default_factory=list)
|
||||
questions: list[str] = field(default_factory=lambda: ["Next question?", "还有吗?"])
|
||||
question_calls: list[tuple[str, str, str, SuggestedQuestionsActor, str]] = field(default_factory=list)
|
||||
generation_calls: list[tuple[str, str, str, bool]] = field(default_factory=list)
|
||||
generation_response: GenerationResponse = field(default_factory=lambda: dict(_BLOCKING))
|
||||
external_error: Exception | None = None
|
||||
@@ -100,21 +83,6 @@ class _Messages:
|
||||
assert persisted.content == feedback.content
|
||||
self.feedback_events.append(feedback)
|
||||
|
||||
def get_suggested_questions(
|
||||
self,
|
||||
*,
|
||||
app_id: str,
|
||||
app_owner_tenant_id: str,
|
||||
expected_app_mode: str,
|
||||
actor: SuggestedQuestionsActor,
|
||||
message_id: str,
|
||||
) -> list[str]:
|
||||
self.assert_sessions_closed()
|
||||
self.question_calls.append((app_id, app_owner_tenant_id, expected_app_mode, actor, message_id))
|
||||
if self.external_error is not None:
|
||||
raise self.external_error
|
||||
return self.questions
|
||||
|
||||
def generate(
|
||||
self, *, app_id: str, account_id: str, args: Mapping[str, object], streaming: bool
|
||||
) -> GenerationResponse:
|
||||
@@ -182,132 +150,17 @@ def messages(
|
||||
usage=SQLAlchemyInstalledAppRepository(session_factory=factory), runtime=state
|
||||
),
|
||||
),
|
||||
message_suggested_questions=state,
|
||||
)
|
||||
monkeypatch.setattr(module, "application_services", lambda: state.services)
|
||||
for resource, suffix in (
|
||||
(module.MessageListApi, ""),
|
||||
(module.MessageFeedbackApi, "/<uuid:message_id>/feedbacks"),
|
||||
(module.MessageMoreLikeThisApi, "/<uuid:message_id>/more-like-this"),
|
||||
(module.MessageSuggestedQuestionApi, "/<uuid:message_id>/suggested-questions"),
|
||||
):
|
||||
harness.api.add_resource(resource, f"/installed-apps/<uuid:installed_app_id>/messages{suffix}")
|
||||
return state
|
||||
|
||||
|
||||
@dataclass
|
||||
class _QuestionProvider:
|
||||
messages: _Messages
|
||||
message: Message
|
||||
legacy_sessions: list[Session] = field(default_factory=list)
|
||||
prompts: list[str] = field(default_factory=list)
|
||||
io_sessions: list[tuple[str, bool]] = field(default_factory=list)
|
||||
token_failure: bool = False
|
||||
traces: list[TraceTask] = field(default_factory=list)
|
||||
|
||||
def get_default_model_instance(self, *, tenant_id: str, model_type: ModelType) -> ModelInstance:
|
||||
assert tenant_id == self.messages.harness.target_app.tenant_id
|
||||
assert model_type == ModelType.LLM
|
||||
self.messages.assert_sessions_closed()
|
||||
assert db.session.get(App, self.messages.harness.target_app.id) is not None
|
||||
return cast(ModelInstance, self)
|
||||
|
||||
def record_io(self, stage: str) -> None:
|
||||
self.io_sessions.append(
|
||||
(
|
||||
stage,
|
||||
all(
|
||||
not session.in_transaction() and not session.identity_map
|
||||
for session in self.messages.sessions + self.legacy_sessions
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int:
|
||||
self.record_io("tokens")
|
||||
if self.token_failure:
|
||||
raise ValueError("Token counter failed")
|
||||
return len(prompt_messages)
|
||||
|
||||
def get_model_schema(self) -> AIModelEntity:
|
||||
self.record_io("schema")
|
||||
return AIModelEntity.model_construct(parameter_rules=[])
|
||||
|
||||
def invoke_llm(
|
||||
self,
|
||||
*,
|
||||
prompt_messages: list[PromptMessage],
|
||||
model_parameters: Mapping[str, object],
|
||||
stop: list[str],
|
||||
stream: bool,
|
||||
) -> LLMResult:
|
||||
self.record_io("invoke")
|
||||
assert stream is False
|
||||
assert stop == []
|
||||
assert model_parameters["temperature"] == 0.0
|
||||
self.prompts.append(prompt_messages[0].get_text_content())
|
||||
if self.messages.external_error is not None:
|
||||
raise self.messages.external_error
|
||||
return LLMResult(
|
||||
model="questions-model",
|
||||
message=AssistantPromptMessage(content=json.dumps(self.messages.questions)),
|
||||
usage=LLMUsage.empty_usage(),
|
||||
)
|
||||
|
||||
def add_trace_task(self, task: TraceTask) -> None:
|
||||
self.traces.append(task)
|
||||
|
||||
def assert_closed(self) -> None:
|
||||
self.messages.assert_sessions_closed()
|
||||
assert all(not session.in_transaction() and not session.identity_map for session in self.legacy_sessions)
|
||||
assert all(closed for _stage, closed in self.io_sessions), self.io_sessions
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_questions(
|
||||
messages: _Messages, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine
|
||||
) -> Iterator[_QuestionProvider]:
|
||||
config = AppModelConfig(
|
||||
app_id=messages.harness.target_app.id,
|
||||
suggested_questions_after_answer='{"enabled":true,"prompt":"Published questions"}',
|
||||
)
|
||||
with messages.factory.begin() as session:
|
||||
session.add_all([messages.harness.account, config])
|
||||
conversation = _conversation(messages)
|
||||
with messages.factory.begin() as session:
|
||||
session.execute(
|
||||
update(Conversation).where(Conversation.id == conversation.id).values(app_model_config_id=config.id)
|
||||
)
|
||||
provider = _QuestionProvider(messages=messages, message=_message(messages, conversation))
|
||||
|
||||
def model_manager(*, tenant_id: str) -> _QuestionProvider:
|
||||
assert tenant_id == messages.harness.target_app.tenant_id
|
||||
return provider
|
||||
|
||||
def trace_manager(*, app_id: str) -> _QuestionProvider:
|
||||
assert app_id == messages.harness.target_app.id
|
||||
return provider
|
||||
|
||||
monkeypatch.setattr(message_module.ModelManager, "for_tenant", model_manager)
|
||||
monkeypatch.setattr(message_module, "TraceQueueManager", trace_manager)
|
||||
messages.services = replace(
|
||||
messages.services,
|
||||
message_suggested_questions=MessageSuggestedQuestionsRuntime(session_factory=messages.factory),
|
||||
)
|
||||
messages.harness.app.config["SQLALCHEMY_DATABASE_URI"] = str(sqlite_engine.url)
|
||||
db.init_app(messages.harness.app)
|
||||
|
||||
def track_legacy(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
provider.legacy_sessions.append(session)
|
||||
|
||||
event.listen(db.session.session_factory, "after_begin", track_legacy)
|
||||
yield provider
|
||||
event.remove(db.session.session_factory, "after_begin", track_legacy)
|
||||
with messages.harness.app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
def _set_mode(state: _Messages, mode: AppMode) -> None:
|
||||
with state.factory.begin() as session:
|
||||
app = session.get(App, state.harness.target_app.id)
|
||||
@@ -608,12 +461,10 @@ def test_all_message_handlers_apply_installed_app_admission(
|
||||
status=403 if admission == "denied" else 404,
|
||||
code="access_denied" if admission == "denied" else "installed_app_not_found",
|
||||
)
|
||||
assert (
|
||||
messages.extra_calls == messages.feedback_events == messages.question_calls == messages.generation_calls == []
|
||||
)
|
||||
assert messages.extra_calls == messages.feedback_events == messages.generation_calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["list", "more-like-this", "suggested-questions"])
|
||||
@pytest.mark.parametrize("operation", ["list", "more-like-this"])
|
||||
def test_mode_rejection_precedes_message_queries_and_generation(messages: _Messages, operation: _Operation) -> None:
|
||||
_set_mode(messages, AppMode.CHAT if operation == "more-like-this" else AppMode.COMPLETION)
|
||||
_error(
|
||||
@@ -621,7 +472,7 @@ def test_mode_rejection_precedes_message_queries_and_generation(messages: _Messa
|
||||
status=400,
|
||||
code="not_completion_app" if operation == "more-like-this" else "not_chat_app",
|
||||
)
|
||||
assert messages.extra_calls == messages.question_calls == messages.generation_calls == []
|
||||
assert messages.extra_calls == messages.generation_calls == []
|
||||
|
||||
|
||||
def test_more_like_this_preserves_blocking_response_and_does_not_update_usage(messages: _Messages) -> None:
|
||||
@@ -674,121 +525,7 @@ def test_more_like_this_invalid_query_uses_shared_422(messages: _Messages, query
|
||||
assert messages.generation_calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("questions", [["Next question?", "还有吗?"], []])
|
||||
def test_suggested_questions_preserves_results_and_empty_fallback(messages: _Messages, questions: list[str]) -> None:
|
||||
messages.questions = questions
|
||||
message_id = str(uuid4())
|
||||
_assert_json_response(
|
||||
messages.request("suggested-questions", message_id=message_id), status=200, body={"data": questions}
|
||||
)
|
||||
assert messages.question_calls == [
|
||||
(
|
||||
messages.harness.target_app.id,
|
||||
messages.harness.target_app.tenant_id,
|
||||
"chat",
|
||||
SuggestedQuestionsAccount(account_id=messages.harness.account.id, invoke_from="explore"),
|
||||
message_id,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [None, InvokeError("Provider rejected input")])
|
||||
def test_shared_suggested_questions_uses_owner_tenant_and_releases_sessions(
|
||||
real_questions: _QuestionProvider, failure: Exception | None
|
||||
) -> None:
|
||||
messages = real_questions.messages
|
||||
assert messages.harness.target_app.tenant_id != messages.harness.installed_app.tenant_id
|
||||
messages.external_error = failure
|
||||
response = messages.request("suggested-questions", message_id=real_questions.message.id)
|
||||
_assert_json_response(response, status=200, body={"data": messages.questions if failure is None else []})
|
||||
assert len(real_questions.traces) == 1
|
||||
assert len(real_questions.prompts) == 1
|
||||
assert real_questions.prompts[0].startswith("Human: Hello\nAssistant: 你好\nPublished questions\n")
|
||||
assert len(real_questions.legacy_sessions) == 2
|
||||
assert real_questions.legacy_sessions[0] is not real_questions.legacy_sessions[1]
|
||||
assert [stage for stage, _closed in real_questions.io_sessions] == ["tokens", "schema", "invoke"]
|
||||
real_questions.assert_closed()
|
||||
messages.assert_unused()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entity", ["message", "conversation"])
|
||||
def test_shared_suggested_questions_rejects_other_account_history(
|
||||
real_questions: _QuestionProvider, entity: str
|
||||
) -> None:
|
||||
messages = real_questions.messages
|
||||
model = Message if entity == "message" else Conversation
|
||||
record_id = real_questions.message.id if entity == "message" else real_questions.message.conversation_id
|
||||
with messages.factory.begin() as session:
|
||||
session.execute(update(model).where(model.id == record_id).values(from_account_id=str(uuid4())))
|
||||
_error(
|
||||
messages.request("suggested-questions", message_id=real_questions.message.id),
|
||||
status=404,
|
||||
code="message_not_found" if entity == "message" else "conversation_not_found",
|
||||
)
|
||||
assert not real_questions.prompts
|
||||
real_questions.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["mode", "owner", "app", "account"])
|
||||
def test_shared_suggested_questions_rejects_stale_admitted_app_and_actor(
|
||||
real_questions: _QuestionProvider, change: str
|
||||
) -> None:
|
||||
messages = real_questions.messages
|
||||
|
||||
def change_admitted_resource() -> None:
|
||||
with messages.factory.begin() as session:
|
||||
if change == "account":
|
||||
account = session.get(Account, messages.harness.account.id)
|
||||
assert account is not None
|
||||
session.delete(account)
|
||||
return
|
||||
app = session.get(App, messages.harness.target_app.id)
|
||||
assert app is not None
|
||||
if change == "mode":
|
||||
app.mode = AppMode.COMPLETION
|
||||
elif change == "owner":
|
||||
app.tenant_id = str(uuid4())
|
||||
else:
|
||||
session.delete(app)
|
||||
|
||||
messages.harness.state.permission_action = change_admitted_resource
|
||||
_error(
|
||||
messages.request("suggested-questions", message_id=real_questions.message.id),
|
||||
status=401 if change == "account" else 400,
|
||||
code="unauthorized" if change == "account" else "app_unavailable",
|
||||
message="Account no longer exists." if change == "account" else None,
|
||||
)
|
||||
assert not real_questions.prompts
|
||||
assert not real_questions.legacy_sessions
|
||||
real_questions.assert_closed()
|
||||
|
||||
|
||||
def test_suggested_questions_uses_admitted_installation_until_next_request(real_questions: _QuestionProvider) -> None:
|
||||
messages = real_questions.messages
|
||||
|
||||
def remove_installation() -> None:
|
||||
with messages.factory.begin() as session:
|
||||
installation = session.get(InstalledApp, messages.harness.installed_app.id)
|
||||
assert installation is not None
|
||||
session.delete(installation)
|
||||
|
||||
messages.harness.state.permission_action = remove_installation
|
||||
_assert_json_response(
|
||||
messages.request("suggested-questions", message_id=real_questions.message.id),
|
||||
status=200,
|
||||
body={"data": messages.questions},
|
||||
)
|
||||
messages.harness.state.permission_action = None
|
||||
_error(
|
||||
messages.request("suggested-questions", message_id=real_questions.message.id),
|
||||
status=404,
|
||||
code="installed_app_not_found",
|
||||
)
|
||||
assert len(real_questions.prompts) == 1
|
||||
real_questions.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["more-like-this", "suggested-questions"])
|
||||
@pytest.mark.parametrize("operation", ["more-like-this"])
|
||||
@pytest.mark.parametrize(
|
||||
("failure", "status", "code", "description"),
|
||||
[
|
||||
@@ -813,7 +550,7 @@ def test_generation_failures_keep_provider_errors_and_context(
|
||||
_set_mode(messages, AppMode.COMPLETION)
|
||||
messages.external_error = failure
|
||||
_error(messages.request(operation), status=status, code=code, message=description)
|
||||
assert len(messages.generation_calls) + len(messages.question_calls) == 1
|
||||
assert len(messages.generation_calls) == 1
|
||||
messages.assert_unused()
|
||||
|
||||
|
||||
@@ -823,15 +560,6 @@ def test_generation_failures_keep_provider_errors_and_context(
|
||||
("more-like-this", MessageNotExistsError(), 404, "message_not_found"),
|
||||
("more-like-this", MoreLikeThisDisabledError(), 403, "app_more_like_this_disabled"),
|
||||
("more-like-this", ValueError("Bad runtime arguments"), 400, "invalid_param"),
|
||||
("suggested-questions", MessageNotExistsError(), 404, "message_not_found"),
|
||||
("suggested-questions", ConversationNotExistsError(), 404, "conversation_not_found"),
|
||||
("suggested-questions", ValueError("Invalid legacy configuration"), 500, "internal_server_error"),
|
||||
(
|
||||
"suggested-questions",
|
||||
SuggestedQuestionsAfterAnswerDisabledError(),
|
||||
403,
|
||||
"app_suggested_questions_after_answer_disabled",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_generation_resource_errors_remain_specific(
|
||||
@@ -842,17 +570,3 @@ def test_generation_resource_errors_remain_specific(
|
||||
messages.external_error = failure
|
||||
_error(messages.request(operation), status=status, code=code)
|
||||
messages.assert_unused()
|
||||
|
||||
|
||||
def test_shared_suggested_questions_token_failure_releases_sessions(real_questions: _QuestionProvider) -> None:
|
||||
real_questions.token_failure = True
|
||||
_error(
|
||||
real_questions.messages.request("suggested-questions", message_id=real_questions.message.id),
|
||||
status=500,
|
||||
code="internal_server_error",
|
||||
)
|
||||
assert [stage for stage, _closed in real_questions.io_sessions] == ["tokens"]
|
||||
assert len(real_questions.legacy_sessions) == 1
|
||||
assert not real_questions.prompts
|
||||
assert not real_questions.traces
|
||||
real_questions.assert_closed()
|
||||
@@ -5,7 +5,6 @@ import zipfile
|
||||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from typing import Literal, cast, override
|
||||
@@ -29,14 +28,12 @@ import controllers.console.explore.trial as trial_module
|
||||
import controllers.console.explore.trial_app_admission as _trial_audio_admission_module
|
||||
import controllers.console.explore.trial_app_admission as _trial_file_upload_admission_module
|
||||
import controllers.console.explore.trial_app_admission as _trial_generation_admission_module
|
||||
import controllers.console.explore.trial_app_admission as _trial_suggested_questions_admission_module
|
||||
import controllers.console.files as files_module
|
||||
import controllers.console.remote_files as remote_module
|
||||
import controllers.console.wraps as console_wraps
|
||||
import libs.login as login_module
|
||||
import services.app_service as app_module
|
||||
import services.file_service as file_module
|
||||
import services.message_service as message_module
|
||||
from constants import COOKIE_NAME_CSRF_TOKEN, HEADER_NAME_CSRF_TOKEN
|
||||
from controllers.common.errors import UnauthorizedError
|
||||
from controllers.console import api as console_api
|
||||
@@ -57,9 +54,6 @@ from core.errors.error import (
|
||||
)
|
||||
from core.file import remote_fetcher
|
||||
from core.helper import encrypter
|
||||
from core.model_context import get_credit_usage_metadata
|
||||
from core.model_manager import ModelInstance
|
||||
from core.ops.ops_trace_manager import TraceTask
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.entities.tool_entities import ToolParameter
|
||||
@@ -67,30 +61,24 @@ from core.tools.errors import ToolSSRFError
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_login import DifyLoginManager, unauthorized_handler
|
||||
from extensions.ext_storage import StorageType
|
||||
from graphon.model_runtime.entities import AssistantPromptMessage, PromptMessage
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
|
||||
from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelType
|
||||
from graphon.model_runtime.errors.invoke import InvokeBadRequestError, InvokeError
|
||||
from graphon.variables import SecretVariable, StringVariable
|
||||
from libs.external_api import ExternalApi
|
||||
from libs.token import generate_csrf_token
|
||||
from machinery.errors import ActiveWorkspaceRequiredError
|
||||
from models import Account, AccountTrialAppRecord, Conversation, Message, Tenant, TrialApp
|
||||
from models import Account, AccountTrialAppRecord, Tenant, TrialApp
|
||||
from models import App as _trial_audio_App
|
||||
from models import App as _trial_file_upload_App
|
||||
from models import App as _trial_generation_App
|
||||
from models import App as _trial_preview_App
|
||||
from models import App as _trial_preview_details_App
|
||||
from models import App as _trial_suggested_questions_App
|
||||
from models import AppMode as _trial_audio_AppMode
|
||||
from models import AppMode as _trial_file_upload_AppMode
|
||||
from models import AppMode as _trial_generation_AppMode
|
||||
from models import AppMode as _trial_preview_AppMode
|
||||
from models import AppMode as _trial_preview_details_AppMode
|
||||
from models import AppMode as _trial_suggested_questions_AppMode
|
||||
from models import UploadFile as _trial_file_upload_UploadFile
|
||||
from models.account import AccountStatus, TenantAccountRole, TenantStatus
|
||||
from models.agent import (
|
||||
@@ -105,7 +93,7 @@ from models.agent import (
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.dataset import Dataset
|
||||
from models.enums import ConversationFromSource, CreatorUserRole, CustomizeTokenStrategy, TagType
|
||||
from models.enums import CreatorUserRole, CustomizeTokenStrategy, TagType
|
||||
from models.model import App as _trial_agent_config_App
|
||||
from models.model import AppMode as _trial_agent_config_AppMode
|
||||
from models.model import AppModelConfig, IconType, RecommendedApp, Site, Tag, TagBinding
|
||||
@@ -139,12 +127,6 @@ from services.errors.conversation import ConversationCompletedError, Conversatio
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.feature_service import FeatureService
|
||||
from services.file_service import FileService
|
||||
from services.message_suggested_questions_adapters import MessageSuggestedQuestionsRuntime
|
||||
from services.message_suggested_questions_service import (
|
||||
SuggestedQuestionsAccount,
|
||||
SuggestedQuestionsActor,
|
||||
SuggestedQuestionsActorNotFoundError,
|
||||
)
|
||||
from services.recommended_app_query_service import (
|
||||
RecommendedAppCatalogPage,
|
||||
RecommendedAppDetailRecord,
|
||||
@@ -3492,583 +3474,3 @@ class TestTrialPreviewDetailsCases:
|
||||
)
|
||||
_trial_preview_details_assert_error(harness.get(endpoint), 400, "app_unavailable")
|
||||
assert harness.io.decryptions == []
|
||||
|
||||
|
||||
"""Trial suggested questions through HTTP, real ownership queries, and SQLite."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TrialSuggestedQuestionsFeatures:
|
||||
enabled: bool = True
|
||||
setup_completed: bool = True
|
||||
events: list[str] = field(default_factory=list)
|
||||
|
||||
def is_trial_enabled(self) -> bool:
|
||||
self.events.append("feature")
|
||||
return self.enabled
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Provider:
|
||||
read_sessions: list[Session]
|
||||
app_id: str
|
||||
model_sessions: list[Session] = field(default_factory=list)
|
||||
token_calls: int = 0
|
||||
tenant_ids: list[str] = field(default_factory=list)
|
||||
prompts: list[str] = field(default_factory=list)
|
||||
traces: list[TraceTask] = field(default_factory=list)
|
||||
questions: list[str] = field(default_factory=lambda: ["Next question?", "Another question?"])
|
||||
failure: Exception | None = None
|
||||
missing_model: bool = False
|
||||
app_type: str = "chatbot"
|
||||
|
||||
def get_default_model_instance(self, *, tenant_id: str, model_type: ModelType) -> ModelInstance:
|
||||
assert model_type == ModelType.LLM
|
||||
self.tenant_ids.append(tenant_id)
|
||||
session = db.session()
|
||||
assert session.get(_trial_suggested_questions_App, self.app_id) is not None
|
||||
self.model_sessions.append(session)
|
||||
if self.missing_model:
|
||||
raise ProviderTokenNotInitError("No default model")
|
||||
return cast(ModelInstance, self)
|
||||
|
||||
def assert_queries_closed(self) -> None:
|
||||
assert all(
|
||||
not session.in_transaction() and not session.identity_map
|
||||
for session in self.read_sessions + self.model_sessions
|
||||
)
|
||||
|
||||
def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int:
|
||||
self.assert_queries_closed()
|
||||
self.token_calls += 1
|
||||
if self.failure is not None:
|
||||
raise self.failure
|
||||
return len(prompt_messages)
|
||||
|
||||
def get_model_schema(self) -> AIModelEntity:
|
||||
self.assert_queries_closed()
|
||||
return AIModelEntity.model_construct(parameter_rules=[])
|
||||
|
||||
def invoke_llm(
|
||||
self,
|
||||
*,
|
||||
prompt_messages: list[PromptMessage],
|
||||
model_parameters: Mapping[str, object],
|
||||
stop: list[str],
|
||||
stream: bool,
|
||||
) -> LLMResult:
|
||||
self.assert_queries_closed()
|
||||
assert self.tenant_ids == [self.tenant_ids[0]] * 2
|
||||
assert get_credit_usage_metadata() == {"app_type": self.app_type, "created_by": "suggested_questions"}
|
||||
assert model_parameters == {"max_tokens": 256, "temperature": 0.0}
|
||||
assert stop == []
|
||||
assert stream is False
|
||||
prompt = prompt_messages[0].get_text_content()
|
||||
assert "Suggest concise follow-ups" in prompt
|
||||
self.prompts.append(prompt)
|
||||
return LLMResult(
|
||||
model="question-model",
|
||||
message=AssistantPromptMessage(content=json.dumps(self.questions)),
|
||||
usage=LLMUsage.empty_usage(),
|
||||
)
|
||||
|
||||
def add_trace_task(self, task: TraceTask) -> None:
|
||||
self.traces.append(task)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _TrialSuggestedQuestionsTrialappservices:
|
||||
access: TrialAppAccessService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _TrialSuggestedQuestionsApplicationservices:
|
||||
trial_apps: _TrialSuggestedQuestionsTrialappservices
|
||||
recommended_app_queries: _TrialSuggestedQuestionsFeatures
|
||||
message_suggested_questions: MessageSuggestedQuestionsRuntime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _TrialSuggestedQuestionsHarness:
|
||||
app: Flask
|
||||
account: Account
|
||||
target: _trial_suggested_questions_App
|
||||
trial: TrialApp
|
||||
conversation: Conversation
|
||||
message: Message
|
||||
config: AppModelConfig
|
||||
factory: sessionmaker[Session]
|
||||
features: _TrialSuggestedQuestionsFeatures
|
||||
provider: _Provider
|
||||
legacy_sessions: list[Session]
|
||||
|
||||
def get(self, *, app_id: str | None = None, message_id: str | None = None) -> TestResponse:
|
||||
return self.app.test_client().get(
|
||||
f"/trial-apps/{app_id or self.target.id}/messages/{message_id or self.message.id}/suggested-questions"
|
||||
)
|
||||
|
||||
def assert_closed(self) -> None:
|
||||
assert all(
|
||||
not session.in_transaction() and not session.identity_map
|
||||
for session in self.provider.read_sessions + self.legacy_sessions
|
||||
)
|
||||
|
||||
def usage(self) -> int | None:
|
||||
with self.factory() as session:
|
||||
return session.scalar(
|
||||
select(AccountTrialAppRecord.count).where(
|
||||
AccountTrialAppRecord.app_id == self.target.id,
|
||||
AccountTrialAppRecord.account_id == self.account.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _trial_suggested_questions_assert_error(
|
||||
response: TestResponse, status: int, code: str, message: str | None = None
|
||||
) -> None:
|
||||
assert response.status_code == status, response.get_json()
|
||||
body = response.get_json()
|
||||
assert body["code"] == code
|
||||
assert body["status"] == status
|
||||
assert body["message"]
|
||||
if message is not None:
|
||||
assert body["message"] == message
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
|
||||
|
||||
class TestTrialSuggestedQuestionsCases:
|
||||
@staticmethod
|
||||
@pytest.fixture
|
||||
def harness(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
config_overrides: Callable[..., None],
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
sqlite_engine: Engine,
|
||||
) -> Iterator[_TrialSuggestedQuestionsHarness]:
|
||||
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, INIT_PASSWORD="", LOGIN_DISABLED=False)
|
||||
features = _TrialSuggestedQuestionsFeatures()
|
||||
account = Account(name="Trial viewer", email="trial@example.com")
|
||||
account._current_tenant = Tenant(name="Viewer workspace")
|
||||
target = _trial_suggested_questions_App(
|
||||
tenant_id=str(uuid4()),
|
||||
name="Trial app",
|
||||
mode=_trial_suggested_questions_AppMode.CHAT,
|
||||
enable_site=True,
|
||||
enable_api=False,
|
||||
)
|
||||
target.id = str(uuid4())
|
||||
trial = TrialApp(app_id=target.id, tenant_id=str(uuid4()), trial_limit=3)
|
||||
config = AppModelConfig(
|
||||
app_id=target.id,
|
||||
suggested_questions_after_answer='{"enabled":true,"prompt":"Suggest concise follow-ups"}',
|
||||
)
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add_all([account, target, trial, config])
|
||||
session.flush()
|
||||
target.app_model_config_id = config.id
|
||||
conversation = Conversation(
|
||||
app_id=target.id,
|
||||
app_model_config_id=config.id,
|
||||
mode=_trial_suggested_questions_AppMode.CHAT,
|
||||
name="Trial conversation",
|
||||
_inputs={},
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
)
|
||||
session.add(conversation)
|
||||
session.flush()
|
||||
message = Message(
|
||||
app_id=target.id,
|
||||
conversation_id=conversation.id,
|
||||
_inputs={},
|
||||
query="What is a trial?",
|
||||
message={},
|
||||
message_unit_price=Decimal(0),
|
||||
answer="A way to try an app.",
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
)
|
||||
session.add(message)
|
||||
|
||||
read_sessions: list[Session] = []
|
||||
read_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
|
||||
@event.listens_for(read_factory, "after_begin")
|
||||
def track_read(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
read_sessions.append(session)
|
||||
|
||||
provider = _Provider(read_sessions, target.id)
|
||||
services = _TrialSuggestedQuestionsApplicationservices(
|
||||
trial_apps=_TrialSuggestedQuestionsTrialappservices(
|
||||
access=TrialAppAccessService(apps=TrialAppRepository(session_factory=read_factory))
|
||||
),
|
||||
recommended_app_queries=features,
|
||||
message_suggested_questions=MessageSuggestedQuestionsRuntime(session_factory=read_factory),
|
||||
)
|
||||
|
||||
def setup_completed() -> bool:
|
||||
features.events.append("setup")
|
||||
return features.setup_completed
|
||||
|
||||
def csrf(_request: Request, account_id: str) -> None:
|
||||
assert account_id == account.id
|
||||
features.events.append("csrf")
|
||||
|
||||
for module in (trial_module, _trial_suggested_questions_admission_module):
|
||||
monkeypatch.setattr(module, "application_services", lambda: services)
|
||||
monkeypatch.setattr(console_wraps, "_is_setup_completed", setup_completed)
|
||||
monkeypatch.setattr(login_module, "current_user", account)
|
||||
monkeypatch.setattr(login_module, "check_csrf_token", csrf)
|
||||
|
||||
def model_manager(*, tenant_id: str) -> _Provider:
|
||||
assert tenant_id == target.tenant_id
|
||||
return provider
|
||||
|
||||
def trace_manager(*, app_id: str) -> _Provider:
|
||||
assert app_id == target.id
|
||||
return provider
|
||||
|
||||
monkeypatch.setattr(message_module.ModelManager, "for_tenant", model_manager)
|
||||
monkeypatch.setattr(message_module, "TraceQueueManager", trace_manager)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
|
||||
db.init_app(app)
|
||||
legacy_sessions: list[Session] = []
|
||||
|
||||
def track_legacy(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
legacy_sessions.append(session)
|
||||
|
||||
event.listen(db.session.session_factory, "after_begin", track_legacy)
|
||||
login_manager = DifyLoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.unauthorized_handler(unauthorized_handler)
|
||||
api = ExternalApi(app)
|
||||
api.add_resource(
|
||||
trial_module.TrialMessageSuggestedQuestionApi,
|
||||
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
|
||||
)
|
||||
yield _TrialSuggestedQuestionsHarness(
|
||||
app,
|
||||
account,
|
||||
target,
|
||||
trial,
|
||||
conversation,
|
||||
message,
|
||||
config,
|
||||
sqlite_session_factory,
|
||||
features,
|
||||
provider,
|
||||
legacy_sessions,
|
||||
)
|
||||
event.remove(db.session.session_factory, "after_begin", track_legacy)
|
||||
with app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize(
|
||||
"mode",
|
||||
[
|
||||
_trial_suggested_questions_AppMode.CHAT,
|
||||
_trial_suggested_questions_AppMode.AGENT_CHAT,
|
||||
_trial_suggested_questions_AppMode.AGENT,
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("questions", [["Next question?", "Another question?"], []])
|
||||
def test_questions_keep_response_history_owner_and_usage(
|
||||
harness: _TrialSuggestedQuestionsHarness, questions: list[str], mode: _trial_suggested_questions_AppMode
|
||||
) -> None:
|
||||
harness.provider.questions = questions
|
||||
harness.provider.app_type = {
|
||||
_trial_suggested_questions_AppMode.CHAT: "chatbot",
|
||||
_trial_suggested_questions_AppMode.AGENT_CHAT: "agent",
|
||||
_trial_suggested_questions_AppMode.AGENT: "agent_v2",
|
||||
}[mode]
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(_trial_suggested_questions_App)
|
||||
.where(_trial_suggested_questions_App.id == harness.target.id)
|
||||
.values(mode=mode)
|
||||
)
|
||||
session.execute(update(Conversation).where(Conversation.id == harness.conversation.id).values(mode=mode))
|
||||
with harness.factory.begin() as session:
|
||||
session.add_all(
|
||||
[
|
||||
AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=1),
|
||||
AccountTrialAppRecord(app_id=harness.target.id, account_id=str(uuid4()), count=3),
|
||||
]
|
||||
)
|
||||
|
||||
response = harness.get()
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json() == {"data": questions}
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
assert int(response.headers["Content-Length"]) == len(response.data)
|
||||
assert len(harness.provider.prompts) == 1
|
||||
assert "Human: What is a trial?\nAssistant: A way to try an app." in harness.provider.prompts[0]
|
||||
assert harness.provider.tenant_ids == [harness.target.tenant_id] * 2
|
||||
assert harness.target.tenant_id not in {harness.trial.tenant_id, harness.account.current_tenant_id}
|
||||
assert len(harness.provider.traces) == 1
|
||||
assert harness.features.events == ["setup", "csrf", "feature"]
|
||||
assert harness.usage() == 1
|
||||
assert len(harness.provider.model_sessions) == 2
|
||||
harness.assert_closed()
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize("entity", ["message", "conversation"])
|
||||
@pytest.mark.parametrize("mismatch", ["missing", "app", "account", "source", "end-user"])
|
||||
def test_message_and_conversation_require_complete_owner_chain(
|
||||
harness: _TrialSuggestedQuestionsHarness, entity: str, mismatch: str
|
||||
) -> None:
|
||||
message_id = harness.message.id
|
||||
with harness.factory.begin() as session:
|
||||
record = (
|
||||
session.get(Message, harness.message.id)
|
||||
if entity == "message"
|
||||
else session.get(Conversation, harness.conversation.id)
|
||||
)
|
||||
assert record is not None
|
||||
match mismatch:
|
||||
case "missing":
|
||||
if entity == "message":
|
||||
message_id = str(uuid4())
|
||||
else:
|
||||
session.delete(record)
|
||||
case "app":
|
||||
record.app_id = str(uuid4())
|
||||
case "account":
|
||||
record.from_account_id = str(uuid4())
|
||||
case "source":
|
||||
record.from_source = ConversationFromSource.API
|
||||
case "end-user":
|
||||
record.from_end_user_id = str(uuid4())
|
||||
|
||||
response = harness.get(message_id=message_id)
|
||||
|
||||
_trial_suggested_questions_assert_error(
|
||||
response, 404, "not_found", "Message not found" if entity == "message" else "Conversation not found"
|
||||
)
|
||||
assert harness.provider.prompts == []
|
||||
assert harness.usage() is None
|
||||
harness.assert_closed()
|
||||
|
||||
@staticmethod
|
||||
def test_deleted_conversation_is_not_visible(harness: _TrialSuggestedQuestionsHarness) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(Conversation).where(Conversation.id == harness.conversation.id).values(is_deleted=True)
|
||||
)
|
||||
_trial_suggested_questions_assert_error(harness.get(), 404, "not_found", "Conversation not found")
|
||||
assert harness.provider.prompts == []
|
||||
harness.assert_closed()
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize("config", [None, '{"enabled":false}'])
|
||||
def test_disabled_or_unset_suggestions_have_specific_error(
|
||||
harness: _TrialSuggestedQuestionsHarness, config: str | None
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.id == harness.config.id)
|
||||
.values(suggested_questions_after_answer=config)
|
||||
)
|
||||
_trial_suggested_questions_assert_error(harness.get(), 403, "app_suggested_questions_after_answer_disabled")
|
||||
assert harness.provider.prompts == []
|
||||
harness.assert_closed()
|
||||
|
||||
@staticmethod
|
||||
def test_absent_default_model_keeps_empty_success(harness: _TrialSuggestedQuestionsHarness) -> None:
|
||||
harness.provider.missing_model = True
|
||||
response = harness.get()
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"data": []}
|
||||
assert harness.provider.prompts == []
|
||||
assert harness.provider.traces == []
|
||||
harness.assert_closed()
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize(
|
||||
"mode", [_trial_suggested_questions_AppMode.COMPLETION, _trial_suggested_questions_AppMode.WORKFLOW]
|
||||
)
|
||||
def test_unsupported_app_modes_fail_before_runtime(
|
||||
harness: _TrialSuggestedQuestionsHarness, mode: _trial_suggested_questions_AppMode
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(_trial_suggested_questions_App)
|
||||
.where(_trial_suggested_questions_App.id == harness.target.id)
|
||||
.values(mode=mode)
|
||||
)
|
||||
_trial_suggested_questions_assert_error(harness.get(), 400, "not_chat_app")
|
||||
assert len(harness.provider.read_sessions) == 1
|
||||
assert harness.legacy_sessions == []
|
||||
harness.assert_closed()
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize(
|
||||
("denial", "status", "code"),
|
||||
[
|
||||
("setup", 401, "not_setup"),
|
||||
("feature", 403, "trial_app_feature_disabled"),
|
||||
("missing_trial", 403, "trial_app_not_allowed"),
|
||||
("missing_app", 403, "trial_app_not_allowed"),
|
||||
("quota", 403, "trial_app_limit_exceeded"),
|
||||
("uninitialized", 400, "account_not_initialized"),
|
||||
("unauthenticated", 401, "unauthorized"),
|
||||
],
|
||||
)
|
||||
def test_admission_blocks_before_runtime(
|
||||
harness: _TrialSuggestedQuestionsHarness, monkeypatch: pytest.MonkeyPatch, denial: str, status: int, code: str
|
||||
) -> None:
|
||||
if denial == "setup":
|
||||
harness.features.setup_completed = False
|
||||
elif denial == "feature":
|
||||
harness.features.enabled = False
|
||||
elif denial == "uninitialized":
|
||||
harness.account.status = AccountStatus.UNINITIALIZED
|
||||
elif denial == "unauthenticated":
|
||||
monkeypatch.setattr(login_module, "current_user", None)
|
||||
else:
|
||||
with harness.factory.begin() as session:
|
||||
if denial == "missing_trial":
|
||||
session.execute(delete(TrialApp).where(TrialApp.app_id == harness.target.id))
|
||||
elif denial == "missing_app":
|
||||
session.execute(
|
||||
delete(_trial_suggested_questions_App).where(
|
||||
_trial_suggested_questions_App.id == harness.target.id
|
||||
)
|
||||
)
|
||||
else:
|
||||
session.add(AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=3))
|
||||
response = harness.get()
|
||||
if denial == "unauthenticated":
|
||||
assert response.status_code == 401
|
||||
assert response.get_json() == {"code": "unauthorized", "message": "Unauthorized.", "status": 401}
|
||||
else:
|
||||
_trial_suggested_questions_assert_error(response, status, code)
|
||||
assert harness.legacy_sessions == []
|
||||
assert harness.provider.prompts == []
|
||||
harness.assert_closed()
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize(
|
||||
("failure", "code", "message"),
|
||||
[
|
||||
(
|
||||
ProviderTokenNotInitError("Missing tenant credentials"),
|
||||
"provider_not_initialize",
|
||||
"Missing tenant credentials",
|
||||
),
|
||||
(QuotaExceededError(), "provider_quota_exceeded", None),
|
||||
(ModelCurrentlyNotSupportError(), "model_currently_not_support", None),
|
||||
(InvokeError("Provider unavailable"), "completion_request_error", "Provider unavailable"),
|
||||
],
|
||||
)
|
||||
def test_token_count_failures_preserve_specific_errors_and_close_session(
|
||||
harness: _TrialSuggestedQuestionsHarness, failure: Exception, code: str, message: str | None
|
||||
) -> None:
|
||||
harness.provider.failure = failure
|
||||
_trial_suggested_questions_assert_error(harness.get(), 400, code, message)
|
||||
assert harness.provider.token_calls == 1
|
||||
assert harness.provider.prompts == []
|
||||
assert harness.provider.traces == []
|
||||
assert harness.usage() is None
|
||||
harness.assert_closed()
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize("failure", [None, InvokeError("Provider unavailable")])
|
||||
def test_runtime_preserves_outer_request_session(
|
||||
harness: _TrialSuggestedQuestionsHarness, failure: Exception | None
|
||||
) -> None:
|
||||
harness.provider.failure = failure
|
||||
with harness.app.app_context():
|
||||
outer_session = db.session()
|
||||
app_model = outer_session.get(_trial_suggested_questions_App, harness.target.id)
|
||||
assert app_model is not None
|
||||
app_model.name = "Pending caller change"
|
||||
|
||||
response = harness.get()
|
||||
|
||||
assert response.status_code == (200 if failure is None else 400)
|
||||
assert db.session() is outer_session
|
||||
assert outer_session.in_transaction()
|
||||
assert app_model in outer_session.dirty
|
||||
assert app_model.name == "Pending caller change"
|
||||
assert harness.provider.model_sessions
|
||||
for inner_session in harness.provider.model_sessions:
|
||||
assert inner_session is not outer_session
|
||||
assert not inner_session.in_transaction()
|
||||
assert not inner_session.identity_map
|
||||
harness.assert_closed()
|
||||
with harness.factory() as session:
|
||||
saved_app = session.get(_trial_suggested_questions_App, harness.target.id)
|
||||
assert saved_app is not None
|
||||
assert saved_app.name == "Trial app"
|
||||
|
||||
@staticmethod
|
||||
def test_advanced_chat_without_published_workflow_keeps_empty_success(
|
||||
harness: _TrialSuggestedQuestionsHarness,
|
||||
) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(_trial_suggested_questions_App)
|
||||
.where(_trial_suggested_questions_App.id == harness.target.id)
|
||||
.values(mode=_trial_suggested_questions_AppMode.ADVANCED_CHAT)
|
||||
)
|
||||
response = harness.get()
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"data": []}
|
||||
assert len(harness.provider.read_sessions) == 2
|
||||
assert harness.provider.prompts == []
|
||||
assert harness.usage() is None
|
||||
harness.assert_closed()
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize(
|
||||
("failure", "status", "code", "message"),
|
||||
[
|
||||
(AppDefinitionUnavailableError("App changed after admission"), 400, "app_unavailable", None),
|
||||
(
|
||||
SuggestedQuestionsActorNotFoundError("Account disappeared"),
|
||||
401,
|
||||
"unauthorized",
|
||||
"Account no longer exists.",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_reload_errors_have_explicit_http_mapping(
|
||||
harness: _TrialSuggestedQuestionsHarness,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
failure: Exception,
|
||||
status: int,
|
||||
code: str,
|
||||
message: str | None,
|
||||
) -> None:
|
||||
def reject_reload(
|
||||
_self: MessageSuggestedQuestionsRuntime,
|
||||
*,
|
||||
app_id: str,
|
||||
app_owner_tenant_id: str,
|
||||
expected_app_mode: str,
|
||||
actor: SuggestedQuestionsActor,
|
||||
message_id: str,
|
||||
) -> list[str]:
|
||||
assert (app_id, app_owner_tenant_id, expected_app_mode, message_id) == (
|
||||
harness.target.id,
|
||||
harness.target.tenant_id,
|
||||
harness.target.mode,
|
||||
harness.message.id,
|
||||
)
|
||||
assert actor == SuggestedQuestionsAccount(account_id=harness.account.id, invoke_from="explore")
|
||||
raise failure
|
||||
|
||||
monkeypatch.setattr(MessageSuggestedQuestionsRuntime, "get_suggested_questions", reject_reload)
|
||||
_trial_suggested_questions_assert_error(harness.get(), status, code, message)
|
||||
assert harness.provider.prompts == []
|
||||
assert harness.usage() is None
|
||||
harness.assert_closed()
|
||||
@@ -23,8 +23,8 @@ import pytest
|
||||
from flask import Flask, request
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
from controllers.common.errors import NotFoundError
|
||||
from controllers.service_api.app.error import NotChatAppError
|
||||
from controllers.service_api.app.message import (
|
||||
AppGetFeedbacksApi,
|
||||
@@ -33,7 +33,6 @@ from controllers.service_api.app.message import (
|
||||
MessageFeedbackPayload,
|
||||
MessageListApi,
|
||||
MessageListQuery,
|
||||
MessageSuggestedApi,
|
||||
)
|
||||
from models.enums import EndUserType, FeedbackRating
|
||||
from models.model import App, AppMode, EndUser
|
||||
@@ -282,11 +281,6 @@ class TestMessageService:
|
||||
assert hasattr(MessageService, "get_all_messages_feedbacks")
|
||||
assert callable(MessageService.get_all_messages_feedbacks)
|
||||
|
||||
def test_get_suggested_questions_after_answer_method_exists(self):
|
||||
"""Test MessageService.get_suggested_questions_after_answer exists."""
|
||||
assert hasattr(MessageService, "get_suggested_questions_after_answer")
|
||||
assert callable(MessageService.get_suggested_questions_after_answer)
|
||||
|
||||
@patch.object(MessageService, "pagination_by_first_id")
|
||||
def test_pagination_by_first_id_returns_pagination_result(self, mock_pagination, orm_session: Session):
|
||||
"""Test pagination_by_first_id returns expected format."""
|
||||
@@ -386,51 +380,6 @@ class TestMessageService:
|
||||
assert len(result) == 2
|
||||
assert result[0]["rating"] == "like"
|
||||
|
||||
@patch.object(MessageService, "get_suggested_questions_after_answer")
|
||||
def test_get_suggested_questions_returns_questions_list(self, mock_get_questions, orm_session: Session):
|
||||
"""Test get_suggested_questions_after_answer returns list of questions."""
|
||||
mock_questions = ["What about this aspect?", "Can you elaborate on that?", "How does this relate to...?"]
|
||||
mock_get_questions.return_value = mock_questions
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=_app(),
|
||||
user=_end_user(),
|
||||
message_id=str(uuid.uuid4()),
|
||||
invoke_from=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
assert len(result) == 3
|
||||
assert isinstance(result[0], str)
|
||||
|
||||
@patch.object(MessageService, "get_suggested_questions_after_answer")
|
||||
def test_get_suggested_questions_raises_disabled_error(self, mock_get_questions, orm_session: Session):
|
||||
"""Test get_suggested_questions_after_answer raises SuggestedQuestionsAfterAnswerDisabledError."""
|
||||
mock_get_questions.side_effect = SuggestedQuestionsAfterAnswerDisabledError()
|
||||
|
||||
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
|
||||
MessageService.get_suggested_questions_after_answer(
|
||||
app_model=_app(),
|
||||
user=_end_user(),
|
||||
message_id=str(uuid.uuid4()),
|
||||
invoke_from=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "get_suggested_questions_after_answer")
|
||||
def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions, orm_session: Session):
|
||||
"""Test get_suggested_questions_after_answer raises MessageNotExistsError."""
|
||||
mock_get_questions.side_effect = MessageNotExistsError()
|
||||
|
||||
with pytest.raises(MessageNotExistsError):
|
||||
MessageService.get_suggested_questions_after_answer(
|
||||
app_model=_app(),
|
||||
user=_end_user(),
|
||||
message_id="invalid_message_id",
|
||||
invoke_from=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
|
||||
class TestMessageListApi:
|
||||
def test_not_chat_app(self, app: Flask) -> None:
|
||||
@@ -466,7 +415,7 @@ class TestMessageListApi:
|
||||
"/messages?conversation_id=00000000-0000-0000-0000-000000000001",
|
||||
method="GET",
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
with pytest.raises(NotFoundError):
|
||||
handler(
|
||||
api,
|
||||
MessageListQuery.model_validate(request.args.to_dict(flat=True)),
|
||||
@@ -490,7 +439,7 @@ class TestMessageListApi:
|
||||
"/messages?conversation_id=00000000-0000-0000-0000-000000000001&first_id=00000000-0000-0000-0000-000000000002",
|
||||
method="GET",
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
with pytest.raises(NotFoundError):
|
||||
handler(
|
||||
api,
|
||||
MessageListQuery.model_validate(request.args.to_dict(flat=True)),
|
||||
@@ -518,7 +467,7 @@ class TestMessageFeedbackApi:
|
||||
json={"rating": "like", "content": "ok"},
|
||||
):
|
||||
payload = MessageFeedbackPayload.model_validate(request.get_json() or {})
|
||||
with pytest.raises(NotFound):
|
||||
with pytest.raises(NotFoundError):
|
||||
handler(api, payload, app_model=app_model, end_user=end_user, message_id="m1")
|
||||
|
||||
|
||||
@@ -549,80 +498,3 @@ class TestAppGetFeedbacksApi:
|
||||
)
|
||||
|
||||
assert response == {"data": [feedback]}
|
||||
|
||||
|
||||
class TestMessageSuggestedApi:
|
||||
def test_not_chat(self, app: Flask) -> None:
|
||||
api = MessageSuggestedApi()
|
||||
handler = unwrap(api.get)
|
||||
app_model = _app(mode=AppMode.COMPLETION)
|
||||
end_user = _end_user()
|
||||
|
||||
with app.test_request_context("/messages/m1/suggested", method="GET"):
|
||||
with pytest.raises(NotChatAppError):
|
||||
handler(api, app_model=app_model, end_user=end_user, message_id="m1")
|
||||
|
||||
def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
MessageService,
|
||||
"get_suggested_questions_after_answer",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(MessageNotExistsError()),
|
||||
)
|
||||
|
||||
api = MessageSuggestedApi()
|
||||
handler = unwrap(api.get)
|
||||
app_model = _app()
|
||||
end_user = _end_user()
|
||||
|
||||
with app.test_request_context("/messages/m1/suggested", method="GET"):
|
||||
with pytest.raises(NotFound):
|
||||
handler(api, app_model=app_model, end_user=end_user, message_id="m1")
|
||||
|
||||
def test_disabled(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
MessageService,
|
||||
"get_suggested_questions_after_answer",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(SuggestedQuestionsAfterAnswerDisabledError()),
|
||||
)
|
||||
|
||||
api = MessageSuggestedApi()
|
||||
handler = unwrap(api.get)
|
||||
app_model = _app()
|
||||
end_user = _end_user()
|
||||
|
||||
with app.test_request_context("/messages/m1/suggested", method="GET"):
|
||||
with pytest.raises(BadRequest):
|
||||
handler(api, app_model=app_model, end_user=end_user, message_id="m1")
|
||||
|
||||
def test_internal_error(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
MessageService,
|
||||
"get_suggested_questions_after_answer",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
|
||||
api = MessageSuggestedApi()
|
||||
handler = unwrap(api.get)
|
||||
app_model = _app()
|
||||
end_user = _end_user()
|
||||
|
||||
with app.test_request_context("/messages/m1/suggested", method="GET"):
|
||||
with pytest.raises(InternalServerError):
|
||||
handler(api, app_model=app_model, end_user=end_user, message_id="m1")
|
||||
|
||||
def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
MessageService,
|
||||
"get_suggested_questions_after_answer",
|
||||
lambda *_args, **_kwargs: ["q1"],
|
||||
)
|
||||
|
||||
api = MessageSuggestedApi()
|
||||
handler = unwrap(api.get)
|
||||
app_model = _app()
|
||||
end_user = _end_user()
|
||||
|
||||
with app.test_request_context("/messages/m1/suggested", method="GET"):
|
||||
response = handler(api, app_model=app_model, end_user=end_user, message_id="m1")
|
||||
|
||||
assert response == {"result": "success", "data": ["q1"]}
|
||||
@@ -13,19 +13,17 @@ from uuid import uuid4
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.views import MethodView
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
|
||||
from controllers.common.errors import NotFoundError
|
||||
from controllers.web.error import (
|
||||
AppMoreLikeThisDisabledError,
|
||||
NotChatAppError,
|
||||
NotCompletionAppError,
|
||||
)
|
||||
from controllers.web.message import (
|
||||
MessageFeedbackApi,
|
||||
MessageMoreLikeThisApi,
|
||||
MessageMoreLikeThisQuery,
|
||||
MessageSuggestedQuestionApi,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.entities.execution_extra_content import HumanInputContent
|
||||
@@ -100,7 +98,7 @@ class TestMessageEndpointsCases:
|
||||
msg_id = uuid4()
|
||||
|
||||
with app.test_request_context(f"/messages/{msg_id}/feedbacks", method="POST"):
|
||||
with pytest.raises(NotFound, match="Message Not Exists"):
|
||||
with pytest.raises(NotFoundError, match="Message Not Exists"):
|
||||
_feedback_post(MessageFeedbackApi(), payload, _chat_app(), _end_user(), msg_id)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -139,7 +137,7 @@ class TestMessageEndpointsCases:
|
||||
query = MessageMoreLikeThisQuery.model_validate({"response_mode": "blocking"})
|
||||
session = MagicMock()
|
||||
with app.test_request_context(f"/messages/{msg_id}/more-like-this?response_mode=blocking"):
|
||||
with pytest.raises(NotFound, match="Message Not Exists"):
|
||||
with pytest.raises(NotFoundError, match="Message Not Exists"):
|
||||
_more_like_this_get(
|
||||
MessageMoreLikeThisApi(), query, session, _completion_app(), _end_user(), msg_id
|
||||
)
|
||||
@@ -161,32 +159,6 @@ class TestMessageEndpointsCases:
|
||||
# ---------------------------------------------------------------------------
|
||||
# MessageSuggestedQuestionApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestMessageSuggestedQuestionApi:
|
||||
def test_wrong_mode_raises(self, app: Flask) -> None:
|
||||
msg_id = uuid4()
|
||||
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
|
||||
with pytest.raises(NotChatAppError):
|
||||
MessageSuggestedQuestionApi().get(_completion_app(), _end_user(), msg_id)
|
||||
|
||||
@patch("controllers.web.message.MessageService.get_suggested_questions_after_answer")
|
||||
def test_happy_path(self, mock_suggest: MagicMock, app: Flask) -> None:
|
||||
msg_id = uuid4()
|
||||
mock_suggest.return_value = ["What about X?", "Tell me more about Y."]
|
||||
|
||||
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
|
||||
result = MessageSuggestedQuestionApi().get(_chat_app(), _end_user(), msg_id)
|
||||
|
||||
assert result["data"] == ["What about X?", "Tell me more about Y."]
|
||||
|
||||
@patch(
|
||||
"controllers.web.message.MessageService.get_suggested_questions_after_answer",
|
||||
side_effect=MessageNotExistsError(),
|
||||
)
|
||||
def test_message_not_found(self, mock_suggest: MagicMock, app: Flask) -> None:
|
||||
msg_id = uuid4()
|
||||
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
|
||||
with pytest.raises(NotFound, match="Message not found"):
|
||||
MessageSuggestedQuestionApi().get(_chat_app(), _end_user(), msg_id)
|
||||
|
||||
|
||||
"""Unit tests for controllers.web.message message list mapping."""
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -13,21 +12,17 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
|
||||
import core.llm_generator.llm_generator as generator_module
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from core.app.entities.app_invoke_entities import CreditUsageCreatedBy
|
||||
from core.llm_generator import llm_generator as llm_generator_module
|
||||
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
|
||||
from core.llm_generator.llm_generator import LLMGenerator, _parse_string_list
|
||||
from core.model_context import get_credit_usage_metadata
|
||||
from core.model_manager import ModelManager
|
||||
from core.plugin.impl.base import _get_plugin_daemon_request_timeout
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_manager
|
||||
from core.workflow.generator import tool_catalogue as tool_catalogue_module
|
||||
from core.workflow.generator.tool_catalogue import ToolCatalogueEntry
|
||||
from graphon.enums import WorkflowNodeExecutionStatus
|
||||
from graphon.model_runtime.entities.llm_entities import LLMMode, LLMResult, LLMUsage
|
||||
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
|
||||
from graphon.model_runtime.entities.model_entities import ModelType, ParameterType
|
||||
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from models.dataset import Dataset
|
||||
from models.enums import ConversationFromSource, CreatorUserRole
|
||||
from models.model import App, AppMode, Message
|
||||
@@ -37,7 +32,7 @@ from models.workflow import (
|
||||
WorkflowNodeExecutionTriggeredFrom,
|
||||
)
|
||||
from services.workflow_service import WorkflowService
|
||||
from tests.helpers.unit.core.model_fixtures import make_model_config, make_model_instance
|
||||
from tests.helpers.unit.core.model_fixtures import make_model_instance
|
||||
from tests.helpers.unit.model_factories import make_dataset
|
||||
|
||||
"""Tests for LLM generation and database-backed instruction modification."""
|
||||
@@ -263,283 +258,6 @@ class TestLlmGeneratorCases:
|
||||
assert len(name) == 78 # 75 + "..."
|
||||
assert name.endswith("...")
|
||||
|
||||
def test_generate_suggested_questions_after_answer_success(self, mock_model_instance):
|
||||
mock_response = MagicMock()
|
||||
mock_response.message.get_text_content.return_value = '["Question 1?", "Question 2?"]'
|
||||
mock_model_instance.invoke_llm.return_value = mock_response
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
|
||||
assert len(questions) == 2
|
||||
assert questions[0] == "Question 1?"
|
||||
assert mock_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
|
||||
def test_generate_suggested_questions_after_answer_uses_lowest_reasoning_effort(self, mock_model_instance):
|
||||
mock_response = MagicMock()
|
||||
mock_response.message.get_text_content.return_value = '["Question 1?"]'
|
||||
mock_model_instance.invoke_llm.return_value = mock_response
|
||||
mock_model_instance.get_model_schema.return_value.parameter_rules = [
|
||||
SimpleNamespace(
|
||||
name="reasoning_effort",
|
||||
type=ParameterType.STRING,
|
||||
options=["minimal", "low", "medium", "high"],
|
||||
)
|
||||
]
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
|
||||
|
||||
assert questions == ["Question 1?"]
|
||||
assert mock_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.0,
|
||||
"reasoning_effort": "minimal",
|
||||
}
|
||||
|
||||
def test_generate_suggested_questions_after_answer_uses_defaults_when_schema_lookup_fails(
|
||||
self, mock_model_instance
|
||||
):
|
||||
mock_response = MagicMock()
|
||||
mock_response.message.get_text_content.return_value = '["Question 1?"]'
|
||||
mock_model_instance.invoke_llm.return_value = mock_response
|
||||
mock_model_instance.get_model_schema.side_effect = ValueError("schema unavailable")
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
|
||||
|
||||
assert questions == ["Question 1?"]
|
||||
assert mock_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("parameter_type", "options", "expected_value"),
|
||||
[
|
||||
(ParameterType.BOOLEAN, [], False),
|
||||
(ParameterType.STRING, ["enabled", "disabled"], "disabled"),
|
||||
],
|
||||
)
|
||||
def test_generate_suggested_questions_after_answer_disables_thinking(
|
||||
self, mock_model_instance, parameter_type, options, expected_value
|
||||
):
|
||||
mock_response = MagicMock()
|
||||
mock_response.message.get_text_content.return_value = '["Question 1?"]'
|
||||
mock_model_instance.invoke_llm.return_value = mock_response
|
||||
mock_model_instance.get_model_schema.return_value.parameter_rules = [
|
||||
SimpleNamespace(name="thinking", type=parameter_type, options=options),
|
||||
SimpleNamespace(name="reasoning_effort", type=ParameterType.STRING, options=["low", "high"]),
|
||||
]
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
|
||||
|
||||
assert questions == ["Question 1?"]
|
||||
assert mock_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.0,
|
||||
"thinking": expected_value,
|
||||
}
|
||||
|
||||
def test_generate_suggested_questions_after_answer_auth_error(self, mock_model_instance):
|
||||
with patch("core.llm_generator.llm_generator.ModelManager.for_tenant") as mock_manager:
|
||||
mock_manager.return_value.get_default_model_instance.side_effect = InvokeAuthorizationError(
|
||||
"Auth failed"
|
||||
)
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
|
||||
assert questions == []
|
||||
|
||||
def test_generate_suggested_questions_after_answer_model_resolution_error(self, mock_model_instance):
|
||||
with patch("core.llm_generator.llm_generator.ModelManager.for_tenant") as mock_manager:
|
||||
mock_manager.return_value.get_default_model_instance.side_effect = ValueError(
|
||||
"unsupported default model"
|
||||
)
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
|
||||
|
||||
assert questions == []
|
||||
|
||||
def test_generate_suggested_questions_after_answer_invoke_error(self, mock_model_instance):
|
||||
mock_model_instance.invoke_llm.side_effect = InvokeError("Invoke failed")
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
|
||||
assert questions == []
|
||||
|
||||
def test_generate_suggested_questions_after_answer_exception(self, mock_model_instance):
|
||||
mock_model_instance.invoke_llm.side_effect = Exception("Random error")
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
|
||||
assert questions == []
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
def test_generate_suggested_questions_after_answer_with_custom_model_and_prompt(self, mock_for_tenant):
|
||||
custom_model_instance = MagicMock()
|
||||
custom_response = MagicMock()
|
||||
custom_response.message.get_text_content.return_value = '["Question 1?"]'
|
||||
custom_model_instance.invoke_llm.return_value = custom_response
|
||||
|
||||
mock_for_tenant.return_value.get_model_instance.return_value = custom_model_instance
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
"tenant_id",
|
||||
"histories",
|
||||
instruction_prompt="custom prompt",
|
||||
model_config={
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
},
|
||||
)
|
||||
|
||||
assert questions == ["Question 1?"]
|
||||
mock_for_tenant.return_value.get_model_instance.assert_called_once_with(
|
||||
tenant_id="tenant_id",
|
||||
model_type=ModelType.LLM,
|
||||
provider="openai",
|
||||
model="gpt-4o",
|
||||
)
|
||||
|
||||
invoke_kwargs = custom_model_instance.invoke_llm.call_args.kwargs
|
||||
assert invoke_kwargs["model_parameters"] == {"temperature": 0.2}
|
||||
assert invoke_kwargs["stop"] == []
|
||||
assert "custom prompt" in invoke_kwargs["prompt_messages"][0].content
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
def test_generate_suggested_questions_after_answer_with_custom_model_without_completion_params(
|
||||
self, mock_for_tenant
|
||||
):
|
||||
custom_model_instance = MagicMock()
|
||||
custom_response = MagicMock()
|
||||
custom_response.message.get_text_content.return_value = '["Question 1?"]'
|
||||
custom_model_instance.invoke_llm.return_value = custom_response
|
||||
mock_for_tenant.return_value.get_model_instance.return_value = custom_model_instance
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
"tenant_id",
|
||||
"histories",
|
||||
model_config={"provider": "openai", "name": "gpt-4o"},
|
||||
)
|
||||
|
||||
assert questions == ["Question 1?"]
|
||||
invoke_kwargs = custom_model_instance.invoke_llm.call_args.kwargs
|
||||
assert invoke_kwargs["model_parameters"] == {}
|
||||
assert invoke_kwargs["stop"] == []
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
def test_generate_suggested_questions_after_answer_fallback_to_default_model(self, mock_for_tenant):
|
||||
default_model_instance = MagicMock()
|
||||
default_response = MagicMock()
|
||||
default_response.message.get_text_content.return_value = '["Question 1?"]'
|
||||
default_model_instance.invoke_llm.return_value = default_response
|
||||
|
||||
mock_for_tenant.return_value.get_model_instance.side_effect = ValueError("invalid configured model")
|
||||
mock_for_tenant.return_value.get_default_model_instance.return_value = default_model_instance
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
"tenant_id",
|
||||
"histories",
|
||||
model_config={
|
||||
"provider": "openai",
|
||||
"name": "not-found-model",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
},
|
||||
)
|
||||
|
||||
assert questions == ["Question 1?"]
|
||||
mock_for_tenant.return_value.get_default_model_instance.assert_called_once_with(
|
||||
tenant_id="tenant_id",
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
assert default_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
assert default_model_instance.invoke_llm.call_args.kwargs["stop"] == []
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
def test_generate_suggested_questions_after_answer_drops_non_positive_max_tokens(self, mock_for_tenant):
|
||||
custom_model_instance = MagicMock()
|
||||
custom_response = MagicMock()
|
||||
custom_response.message.get_text_content.return_value = '["Question 1?"]'
|
||||
custom_model_instance.invoke_llm.return_value = custom_response
|
||||
mock_for_tenant.return_value.get_model_instance.return_value = custom_model_instance
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
"tenant_id",
|
||||
"histories",
|
||||
model_config={
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o",
|
||||
"completion_params": {
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 0,
|
||||
"stop": ["END"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert questions == ["Question 1?"]
|
||||
invoke_kwargs = custom_model_instance.invoke_llm.call_args.kwargs
|
||||
assert invoke_kwargs["model_parameters"] == {"temperature": 0.2}
|
||||
assert invoke_kwargs["stop"] == ["END"]
|
||||
|
||||
@pytest.mark.parametrize("use_configured_model", [False, True])
|
||||
def test_prepared_suggested_questions_defer_provider_calls_without_resolving_again(
|
||||
self, monkeypatch: pytest.MonkeyPatch, use_configured_model: bool
|
||||
) -> None:
|
||||
model_instance = make_model_instance(provider="openai", model="custom-model")
|
||||
schema = make_model_config(provider="openai", model="custom-model", mode="chat").model_schema
|
||||
get_schema = Mock(return_value=schema)
|
||||
invocation = Mock()
|
||||
monkeypatch.setattr(model_instance, "get_model_schema", get_schema)
|
||||
monkeypatch.setattr(model_instance, "invoke_llm", invocation)
|
||||
manager = create_plugin_model_manager(tenant_id="tenant_id")
|
||||
monkeypatch.setattr(manager, "get_model_instance", Mock(return_value=model_instance))
|
||||
monkeypatch.setattr(manager, "get_default_model_instance", Mock(return_value=model_instance))
|
||||
resolve_manager = Mock(return_value=manager)
|
||||
monkeypatch.setattr(ModelManager, "for_tenant", resolve_manager)
|
||||
original_metadata = get_credit_usage_metadata()
|
||||
original_timeout = _get_plugin_daemon_request_timeout()
|
||||
|
||||
def invoke(**_kwargs: object) -> LLMResult:
|
||||
metadata = get_credit_usage_metadata()
|
||||
assert metadata is not None
|
||||
assert metadata["created_by"] == CreditUsageCreatedBy.SUGGESTED_QUESTIONS
|
||||
timeout = _get_plugin_daemon_request_timeout()
|
||||
assert timeout is not None
|
||||
assert timeout.read == 30.0
|
||||
return _llm_result('["Next question?"]')
|
||||
|
||||
invocation.side_effect = invoke
|
||||
model_config = (
|
||||
{
|
||||
"provider": "openai",
|
||||
"name": "custom-model",
|
||||
"completion_params": {"temperature": 0.2, "stop": ["END"], "max_tokens": 0},
|
||||
}
|
||||
if use_configured_model
|
||||
else None
|
||||
)
|
||||
prepared_model = LLMGenerator.prepare_suggested_questions_model("tenant_id", model_config=model_config)
|
||||
|
||||
assert prepared_model is not None
|
||||
get_schema.assert_not_called()
|
||||
invocation.assert_not_called()
|
||||
resolve_manager.side_effect = AssertionError("Model lookup must finish in the preparation phase")
|
||||
|
||||
result = LLMGenerator.invoke_suggested_questions_after_answer(
|
||||
prepared_model, "Human: hello\nAssistant: world", instruction_prompt="Ask a follow-up"
|
||||
)
|
||||
|
||||
assert result == ["Next question?"]
|
||||
parameters = invocation.call_args.kwargs
|
||||
assert parameters["model_parameters"] == (
|
||||
{"temperature": 0.2} if use_configured_model else {"max_tokens": 256, "temperature": 0.0}
|
||||
)
|
||||
assert parameters["stop"] == (["END"] if use_configured_model else [])
|
||||
assert parameters["stream"] is False
|
||||
assert "Human: hello\nAssistant: world" in parameters["prompt_messages"][0].content
|
||||
assert "Ask a follow-up" in parameters["prompt_messages"][0].content
|
||||
assert get_credit_usage_metadata() == original_metadata
|
||||
assert _get_plugin_daemon_request_timeout() == original_timeout
|
||||
|
||||
def test_generate_rule_config_no_variable_success(self, mock_model_instance, model_config_entity):
|
||||
payload = RuleGeneratePayload(
|
||||
instruction="test instruction", model_config=model_config_entity, no_variable=True
|
||||
|
||||
@@ -2086,3 +2086,47 @@ class TestWorkflowRunAccessorsCases:
|
||||
assert payload["limit"] == 20
|
||||
assert payload["has_more"] is False
|
||||
assert [item.created_by_account.id for item in payload["data"]] == [account.id, account.id]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("features", "expected"),
|
||||
[
|
||||
pytest.param({}, {}, id="missing-upload"),
|
||||
pytest.param({"file_upload": None}, {"file_upload": None}, id="null-upload"),
|
||||
pytest.param({"file_upload": False}, {"file_upload": False}, id="non-object-upload"),
|
||||
pytest.param({"file_upload": {}}, {"file_upload": {}}, id="missing-image"),
|
||||
pytest.param({"file_upload": {"image": None}}, {"file_upload": {"image": None}}, id="null-image"),
|
||||
pytest.param({"file_upload": {"image": False}}, {"file_upload": {"image": False}}, id="non-object-image"),
|
||||
pytest.param(
|
||||
{"file_upload": {"enabled": False, "image": {"enabled": False}}},
|
||||
{"file_upload": {"enabled": False, "image": {"enabled": False}}},
|
||||
id="disabled-image",
|
||||
),
|
||||
pytest.param(OLD_VERSION_WORKFLOW_FEATURES, NEW_VERSION_WORKFLOW_FEATURES, id="legacy-image"),
|
||||
pytest.param(NEW_VERSION_WORKFLOW_FEATURES, NEW_VERSION_WORKFLOW_FEATURES, id="modern-upload"),
|
||||
],
|
||||
)
|
||||
def test_normalized_features_preserves_stored_payload_without_dirtying_workflow(
|
||||
sqlite_session: Session,
|
||||
features: dict[str, JsonValue],
|
||||
expected: dict[str, JsonValue],
|
||||
) -> None:
|
||||
stored_features = json.dumps(features)
|
||||
workflow = _workflow_feature_compatibility_Workflow(
|
||||
tenant_id=str(uuid4()),
|
||||
app_id=str(uuid4()),
|
||||
type=WorkflowType.CHAT,
|
||||
version="published",
|
||||
graph="{}",
|
||||
features=stored_features,
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
sqlite_session.add(workflow)
|
||||
sqlite_session.commit()
|
||||
|
||||
assert workflow.normalized_features_dict == expected
|
||||
assert workflow.serialized_features == stored_features
|
||||
assert workflow not in sqlite_session.dirty
|
||||
|
||||
sqlite_session.expire(workflow)
|
||||
assert workflow.serialized_features == stored_features
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
@@ -11,18 +10,7 @@ from sqlalchemy.orm import Session, scoped_session
|
||||
|
||||
import models.model as model_module
|
||||
import services.message_service as service_module
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models.account import Account, AccountStatus
|
||||
from models.agent import (
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigSnapshot,
|
||||
AgentConfigVersionKind,
|
||||
AgentDebugConversation,
|
||||
AgentWorkspaceBinding,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import (
|
||||
ConversationFromSource,
|
||||
EndUserType,
|
||||
@@ -31,40 +19,20 @@ from models.enums import (
|
||||
)
|
||||
from models.model import (
|
||||
App,
|
||||
AppAnnotationSetting,
|
||||
AppMode,
|
||||
AppModelConfig,
|
||||
Conversation,
|
||||
EndUser,
|
||||
Message,
|
||||
MessageFeedback,
|
||||
)
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
|
||||
from services.errors.message import (
|
||||
FirstMessageNotExistsError,
|
||||
LastMessageNotExistsError,
|
||||
MessageNotExistsError,
|
||||
SuggestedQuestionsAfterAnswerDisabledError,
|
||||
)
|
||||
from services.message_service import MessageService, attach_message_extra_contents
|
||||
|
||||
SQLITE_MODELS = (
|
||||
Conversation,
|
||||
Message,
|
||||
MessageFeedback,
|
||||
AppModelConfig,
|
||||
AppAnnotationSetting,
|
||||
AgentConfigDraft,
|
||||
AgentConfigSnapshot,
|
||||
AgentDebugConversation,
|
||||
AgentWorkspaceBinding,
|
||||
)
|
||||
pytestmark = [
|
||||
pytest.mark.usefixtures("sqlite_session"),
|
||||
pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True),
|
||||
]
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class _DatabaseBinding:
|
||||
@@ -110,19 +78,6 @@ class MessageServiceTestDataFactory:
|
||||
account.id = user_id
|
||||
return account
|
||||
|
||||
@staticmethod
|
||||
def create_workflow(*, features: dict[str, object] | None = None) -> Workflow:
|
||||
return Workflow(
|
||||
id="workflow-123",
|
||||
tenant_id="tenant-123",
|
||||
app_id="app-123",
|
||||
type=WorkflowType.CHAT,
|
||||
version="1",
|
||||
graph="{}",
|
||||
_features=json.dumps(features or {}),
|
||||
created_by="account-123",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_conversation(
|
||||
conversation_id: str = "conv-001",
|
||||
@@ -700,458 +655,3 @@ class TestMessageServiceFeedback:
|
||||
)
|
||||
|
||||
assert [record["id"] for record in result] == [newest.id]
|
||||
|
||||
|
||||
class TestMessageServiceSuggestedQuestions:
|
||||
@staticmethod
|
||||
def _agent_soul(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
prompt: str | None = None,
|
||||
model: dict[str, object] | None = None,
|
||||
) -> AgentSoulConfig:
|
||||
suggested_questions: dict[str, object] = {"enabled": enabled}
|
||||
if prompt is not None:
|
||||
suggested_questions["prompt"] = prompt
|
||||
if model is not None:
|
||||
suggested_questions["model"] = model
|
||||
return AgentSoulConfig.model_validate(
|
||||
{"app_features": {"suggested_questions_after_answer": suggested_questions}}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _chat_boundaries(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
conversation: Conversation,
|
||||
) -> tuple[MagicMock, MagicMock, MagicMock]:
|
||||
message = MessageServiceTestDataFactory.create_message(message_id="msg-123", conversation_id=conversation.id)
|
||||
monkeypatch.setattr(service_module.MessageService, "get_message", MagicMock(return_value=message))
|
||||
monkeypatch.setattr(
|
||||
service_module.ConversationService, "get_conversation", MagicMock(return_value=conversation)
|
||||
)
|
||||
model_manager = MagicMock()
|
||||
monkeypatch.setattr(service_module.ModelManager, "for_tenant", MagicMock(return_value=model_manager))
|
||||
memory = MagicMock()
|
||||
memory.return_value.get_history_prompt_text.return_value = "histories"
|
||||
monkeypatch.setattr(service_module, "TokenBufferMemory", memory)
|
||||
llm_generator = MagicMock()
|
||||
llm_generator.generate_suggested_questions_after_answer.return_value = ["Q1?"]
|
||||
monkeypatch.setattr(service_module, "LLMGenerator", llm_generator)
|
||||
monkeypatch.setattr(service_module, "TraceQueueManager", MagicMock())
|
||||
return model_manager, memory, llm_generator
|
||||
|
||||
def test_user_none(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None:
|
||||
with pytest.raises(ValueError, match="user cannot be None"):
|
||||
MessageService.get_suggested_questions_after_answer(
|
||||
app_model=factory.create_app(),
|
||||
user=None,
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
def test_advanced_chat_success(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
conversation = factory.create_conversation()
|
||||
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
|
||||
workflow = factory.create_workflow(features={"suggested_questions_after_answer": {"enabled": True}})
|
||||
workflow_service = MagicMock()
|
||||
workflow_service.return_value.get_published_workflow.return_value = workflow
|
||||
monkeypatch.setattr(service_module, "WorkflowService", workflow_service)
|
||||
app_config_manager = MagicMock()
|
||||
app_config_manager.get_app_config.return_value.additional_features.suggested_questions_after_answer = True
|
||||
monkeypatch.setattr(service_module, "AdvancedChatAppConfigManager", app_config_manager)
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=factory.create_app(mode=AppMode.ADVANCED_CHAT),
|
||||
user=factory.create_end_user(),
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
llm_generator.generate_suggested_questions_after_answer.assert_called_once()
|
||||
|
||||
@pytest.mark.parametrize("draft_type", [AgentConfigDraftType.DRAFT, AgentConfigDraftType.DEBUG_BUILD])
|
||||
def test_agent_debug_uses_matching_draft(
|
||||
self,
|
||||
draft_type: AgentConfigDraftType,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
conversation = factory.create_conversation()
|
||||
conversation.mode = AppMode.AGENT
|
||||
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
|
||||
account = factory.create_account()
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id="tenant-123",
|
||||
agent_id="agent-123",
|
||||
draft_type=draft_type,
|
||||
account_id=account.id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None,
|
||||
draft_owner_key=account.id if draft_type == AgentConfigDraftType.DEBUG_BUILD else "",
|
||||
base_snapshot_id=None,
|
||||
home_snapshot_id=None,
|
||||
agent_workspace_binding_id=None,
|
||||
config_snapshot=self._agent_soul(
|
||||
prompt=f"{draft_type.value} prompt",
|
||||
model={
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {"temperature": 0.1},
|
||||
},
|
||||
),
|
||||
created_by=account.id,
|
||||
updated_by=account.id,
|
||||
)
|
||||
draft.id = "draft-123"
|
||||
mapping = AgentDebugConversation(
|
||||
tenant_id="tenant-123",
|
||||
agent_id="agent-123",
|
||||
app_id="app-123",
|
||||
account_id=account.id,
|
||||
draft_type=draft_type,
|
||||
conversation_id=conversation.id,
|
||||
)
|
||||
mapping.id = "mapping-123"
|
||||
app_model_config = AppModelConfig(
|
||||
app_id="app-123",
|
||||
suggested_questions_after_answer=json.dumps({"enabled": False}),
|
||||
)
|
||||
app_model_config.id = "config-123"
|
||||
_persist(sqlite_session, draft, mapping, app_model_config)
|
||||
roster_service = MagicMock()
|
||||
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
|
||||
app = factory.create_app(mode=AppMode.AGENT)
|
||||
app.app_model_config_id = app_model_config.id
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app,
|
||||
user=account,
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
roster_service.assert_not_called()
|
||||
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
histories="histories",
|
||||
instruction_prompt=f"{draft_type.value} prompt",
|
||||
model_config={
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {"temperature": 0.1},
|
||||
},
|
||||
)
|
||||
|
||||
def test_agent_published_conversation_uses_bound_snapshot(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
conversation = factory.create_conversation()
|
||||
conversation.mode = AppMode.AGENT
|
||||
conversation.agent_workspace_binding_id = "binding-123"
|
||||
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
|
||||
snapshot = AgentConfigSnapshot(
|
||||
tenant_id="tenant-123",
|
||||
agent_id="agent-123",
|
||||
version=1,
|
||||
config_snapshot=self._agent_soul(prompt="bound snapshot prompt"),
|
||||
home_snapshot_id=None,
|
||||
summary=None,
|
||||
version_note=None,
|
||||
created_by="account-123",
|
||||
)
|
||||
snapshot.id = "snapshot-123"
|
||||
binding = AgentWorkspaceBinding(
|
||||
tenant_id="tenant-123",
|
||||
app_id="app-123",
|
||||
workspace_id="workspace-123",
|
||||
agent_id="agent-123",
|
||||
base_home_snapshot_id=None,
|
||||
agent_config_version_id=snapshot.id,
|
||||
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
backend_binding_ref="backend-binding-123",
|
||||
session_snapshot=None,
|
||||
retired_at=None,
|
||||
pending_form_id=None,
|
||||
pending_tool_call_id=None,
|
||||
)
|
||||
binding.id = "binding-123"
|
||||
_persist(sqlite_session, snapshot, binding)
|
||||
roster_service = MagicMock()
|
||||
roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul(
|
||||
prompt="current published prompt"
|
||||
)
|
||||
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=factory.create_app(mode=AppMode.AGENT),
|
||||
user=factory.create_end_user(),
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
roster_service.assert_not_called()
|
||||
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
histories="histories",
|
||||
instruction_prompt="bound snapshot prompt",
|
||||
model_config=None,
|
||||
)
|
||||
|
||||
def test_agent_without_binding_uses_published_soul(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
conversation = factory.create_conversation()
|
||||
conversation.mode = AppMode.AGENT
|
||||
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
|
||||
roster_service = MagicMock()
|
||||
roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul(
|
||||
prompt="published prompt"
|
||||
)
|
||||
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=factory.create_app(mode=AppMode.AGENT),
|
||||
user=factory.create_end_user(),
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
roster_service.return_value.get_published_agent_soul_for_app.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
app_id="app-123",
|
||||
)
|
||||
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
histories="histories",
|
||||
instruction_prompt="published prompt",
|
||||
model_config=None,
|
||||
)
|
||||
|
||||
def test_agent_unavailable_default_model_returns_empty_questions(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
conversation = factory.create_conversation()
|
||||
conversation.mode = AppMode.AGENT
|
||||
model_manager, memory, llm_generator = self._chat_boundaries(monkeypatch, conversation)
|
||||
model_manager.get_default_model_instance.side_effect = ValueError("unsupported default model")
|
||||
roster_service = MagicMock()
|
||||
roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul()
|
||||
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=factory.create_app(mode=AppMode.AGENT),
|
||||
user=factory.create_end_user(),
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result == []
|
||||
memory.assert_not_called()
|
||||
llm_generator.generate_suggested_questions_after_answer.assert_not_called()
|
||||
|
||||
def test_historical_agent_without_soul_uses_current_app_model_config(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
conversation = factory.create_conversation()
|
||||
conversation.mode = AppMode.AGENT
|
||||
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
|
||||
app_model_config = AppModelConfig(
|
||||
app_id="app-123",
|
||||
suggested_questions_after_answer=json.dumps({"enabled": True, "prompt": "legacy prompt"}),
|
||||
)
|
||||
app_model_config.id = "config-123"
|
||||
_persist(sqlite_session, app_model_config)
|
||||
app = factory.create_app(mode=AppMode.AGENT)
|
||||
app.app_model_config_id = app_model_config.id
|
||||
roster_service = MagicMock()
|
||||
roster_service.return_value.get_published_agent_soul_for_app.return_value = None
|
||||
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app,
|
||||
user=factory.create_end_user(),
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
histories="histories",
|
||||
instruction_prompt="legacy prompt",
|
||||
model_config=None,
|
||||
)
|
||||
|
||||
def test_agent_disabled_raises_disabled_error(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
conversation = factory.create_conversation()
|
||||
conversation.mode = AppMode.AGENT
|
||||
self._chat_boundaries(monkeypatch, conversation)
|
||||
roster_service = MagicMock()
|
||||
roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul(enabled=False)
|
||||
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
|
||||
|
||||
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
|
||||
MessageService.get_suggested_questions_after_answer(
|
||||
app_model=factory.create_app(mode=AppMode.AGENT),
|
||||
user=factory.create_end_user(),
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "expected_prompt", "expected_model"),
|
||||
[
|
||||
({"enabled": True}, None, None),
|
||||
(
|
||||
{
|
||||
"enabled": True,
|
||||
"prompt": "custom prompt",
|
||||
"model": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"completion_params": {"max_tokens": 2048, "temperature": 0.1},
|
||||
},
|
||||
},
|
||||
"custom prompt",
|
||||
{
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"completion_params": {"max_tokens": 2048, "temperature": 0.1},
|
||||
},
|
||||
),
|
||||
(
|
||||
{"enabled": True, "model": {"provider": "openai", "name": "invalid-model"}},
|
||||
None,
|
||||
{"provider": "openai", "name": "invalid-model"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_chat_app_uses_persisted_model_config(
|
||||
self,
|
||||
config: dict[str, object],
|
||||
expected_prompt: str | None,
|
||||
expected_model: dict[str, object] | None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
app_model_config = AppModelConfig(
|
||||
app_id="app-123",
|
||||
suggested_questions_after_answer=json.dumps(config),
|
||||
)
|
||||
app_model_config.id = "config-1"
|
||||
conversation = factory.create_conversation(app_model_config_id=app_model_config.id)
|
||||
_persist(sqlite_session, app_model_config)
|
||||
model_manager, memory, llm_generator = self._chat_boundaries(monkeypatch, conversation)
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=factory.create_app(mode=AppMode.CHAT),
|
||||
user=factory.create_end_user(),
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
model_manager.get_default_model_instance.assert_called_once_with(
|
||||
tenant_id="tenant-123", model_type=ModelType.LLM
|
||||
)
|
||||
memory.assert_called_once_with(
|
||||
conversation=conversation,
|
||||
model_instance=model_manager.get_default_model_instance.return_value,
|
||||
)
|
||||
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
histories="histories",
|
||||
instruction_prompt=expected_prompt,
|
||||
model_config=expected_model,
|
||||
)
|
||||
|
||||
def test_chat_app_uses_compatible_override_model_config(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
override = {
|
||||
"model": {"provider": "openai", "name": "gpt-4o-mini", "mode": "chat"},
|
||||
"suggested_questions_after_answer": {"enabled": True, "prompt": "legacy prompt"},
|
||||
}
|
||||
conversation = factory.create_conversation(override_model_configs=json.dumps(override))
|
||||
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=factory.create_app(mode=AppMode.CHAT),
|
||||
user=factory.create_end_user(),
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
histories="histories",
|
||||
instruction_prompt="legacy prompt",
|
||||
model_config=None,
|
||||
)
|
||||
|
||||
def test_disabled_error(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
conversation = factory.create_conversation()
|
||||
self._chat_boundaries(monkeypatch, conversation)
|
||||
workflow = factory.create_workflow()
|
||||
workflow_service = MagicMock()
|
||||
workflow_service.return_value.get_published_workflow.return_value = workflow
|
||||
monkeypatch.setattr(service_module, "WorkflowService", workflow_service)
|
||||
app_config_manager = MagicMock()
|
||||
app_config_manager.get_app_config.return_value.additional_features.suggested_questions_after_answer = False
|
||||
monkeypatch.setattr(service_module, "AdvancedChatAppConfigManager", app_config_manager)
|
||||
|
||||
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
|
||||
MessageService.get_suggested_questions_after_answer(
|
||||
app_model=factory.create_app(mode=AppMode.ADVANCED_CHAT),
|
||||
user=factory.create_end_user(),
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
session=sqlite_session,
|
||||
)
|
||||
@@ -1,637 +0,0 @@
|
||||
"""Shared runtime ownership, configuration selection, and session lifecycle."""
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Literal, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from flask import Flask, g
|
||||
from sqlalchemy import Connection, Engine, event, update
|
||||
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
||||
|
||||
import services.message_service as message_module
|
||||
from core.credit_usage import CreditUsageAppType, CreditUsageCreatedBy
|
||||
from core.file import remote_fetcher
|
||||
from core.model_context import get_credit_usage_metadata, use_credit_usage_metadata
|
||||
from core.model_manager import ModelInstance
|
||||
from core.ops.ops_trace_manager import TraceTask
|
||||
from extensions.ext_database import db
|
||||
from graphon.file import FileTransferMethod, FileType
|
||||
from graphon.model_runtime.entities import AssistantPromptMessage, PromptMessage
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
|
||||
from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelType
|
||||
from models import Account, App, AppMode, Conversation, Message
|
||||
from models.enums import ConversationFromSource, CreatorUserRole, EndUserType
|
||||
from models.model import AppModelConfig, EndUser, MessageFile
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
|
||||
from services.message_suggested_questions_adapters import MessageSuggestedQuestionsRuntime
|
||||
from services.message_suggested_questions_service import (
|
||||
SuggestedQuestionsAccount,
|
||||
SuggestedQuestionsActor,
|
||||
SuggestedQuestionsActorNotFoundError,
|
||||
SuggestedQuestionsEndUser,
|
||||
)
|
||||
|
||||
type _InvokeFrom = Literal["debugger", "explore", "web-app", "service-api"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Provider:
|
||||
read_sessions: list[Session]
|
||||
tenant_id: str
|
||||
app_id: str
|
||||
prompts: list[str] = field(default_factory=list)
|
||||
model_requests: list[tuple[str, str]] = field(default_factory=list)
|
||||
invocation_parameters: list[Mapping[str, object]] = field(default_factory=list)
|
||||
metadata: list[Mapping[str, object] | None] = field(default_factory=list)
|
||||
traces: list[TraceTask] = field(default_factory=list)
|
||||
model_sessions: list[Session] = field(default_factory=list)
|
||||
io_sessions: list[tuple[str, bool]] = field(default_factory=list)
|
||||
failure: Exception | None = None
|
||||
token_failure: Exception | None = None
|
||||
history_model_failure: bool = False
|
||||
generation_model_failure: bool = False
|
||||
|
||||
def record_io(self, stage: str) -> None:
|
||||
self.io_sessions.append(
|
||||
(
|
||||
stage,
|
||||
all(
|
||||
not session.in_transaction() and not session.identity_map
|
||||
for session in self.read_sessions + self.model_sessions
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def _resolve_model(self, *, tenant_id: str, model_type: ModelType) -> ModelInstance:
|
||||
assert tenant_id == self.tenant_id
|
||||
assert model_type == ModelType.LLM
|
||||
assert self.read_sessions
|
||||
assert all(not session.in_transaction() and not session.identity_map for session in self.read_sessions)
|
||||
# Simulate ModelManager's real scoped database lookup, so both resolution
|
||||
# stages must dispose their sessions before token/schema/provider I/O.
|
||||
session = db.session()
|
||||
assert session.get(App, self.app_id) is not None
|
||||
self.model_sessions.append(session)
|
||||
if self.history_model_failure or (self.generation_model_failure and len(self.model_sessions) > 1):
|
||||
raise RuntimeError("No model available")
|
||||
return cast(ModelInstance, self)
|
||||
|
||||
def get_default_model_instance(self, *, tenant_id: str, model_type: ModelType) -> ModelInstance:
|
||||
self.model_requests.append(("default", "default"))
|
||||
return self._resolve_model(tenant_id=tenant_id, model_type=model_type)
|
||||
|
||||
def get_model_instance(self, *, tenant_id: str, model_type: ModelType, provider: str, model: str) -> ModelInstance:
|
||||
self.model_requests.append((provider, model))
|
||||
return self._resolve_model(tenant_id=tenant_id, model_type=model_type)
|
||||
|
||||
def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int:
|
||||
self.record_io("tokens")
|
||||
if self.token_failure is not None:
|
||||
raise self.token_failure
|
||||
return len(prompt_messages)
|
||||
|
||||
def get_model_schema(self) -> AIModelEntity:
|
||||
self.record_io("schema")
|
||||
return AIModelEntity.model_construct(parameter_rules=[])
|
||||
|
||||
def invoke_llm(
|
||||
self,
|
||||
*,
|
||||
prompt_messages: list[PromptMessage],
|
||||
model_parameters: Mapping[str, object],
|
||||
stop: list[str],
|
||||
stream: bool,
|
||||
) -> LLMResult:
|
||||
self.record_io("invoke")
|
||||
assert stream is False
|
||||
assert stop == []
|
||||
self.invocation_parameters.append(model_parameters)
|
||||
self.prompts.append(prompt_messages[0].get_text_content())
|
||||
self.metadata.append(get_credit_usage_metadata())
|
||||
if self.failure is not None:
|
||||
raise self.failure
|
||||
return LLMResult(
|
||||
model="question-model",
|
||||
message=AssistantPromptMessage(content='["What next?"]'),
|
||||
usage=LLMUsage.empty_usage(),
|
||||
)
|
||||
|
||||
def add_trace_task(self, task: TraceTask) -> None:
|
||||
self.traces.append(task)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Harness:
|
||||
flask_app: Flask
|
||||
target: App
|
||||
account: Account
|
||||
end_user: EndUser
|
||||
conversation: Conversation
|
||||
message: Message
|
||||
factory: sessionmaker[Session]
|
||||
runtime: MessageSuggestedQuestionsRuntime
|
||||
provider: _Provider
|
||||
legacy_sessions: list[Session]
|
||||
|
||||
def actor(self, invoke_from: _InvokeFrom) -> SuggestedQuestionsActor:
|
||||
if invoke_from in {"explore", "debugger"}:
|
||||
with self.factory.begin() as session:
|
||||
for model, record_id in ((Message, self.message.id), (Conversation, self.conversation.id)):
|
||||
session.execute(
|
||||
update(model)
|
||||
.where(model.id == record_id)
|
||||
.values(
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=self.account.id,
|
||||
from_end_user_id=None,
|
||||
)
|
||||
)
|
||||
return SuggestedQuestionsAccount(account_id=self.account.id, invoke_from=invoke_from)
|
||||
return SuggestedQuestionsEndUser(end_user_id=self.end_user.id, invoke_from=invoke_from)
|
||||
|
||||
def get(self, actor: SuggestedQuestionsActor) -> list[str]:
|
||||
return self.runtime.get_suggested_questions(
|
||||
app_id=self.target.id,
|
||||
app_owner_tenant_id=self.target.tenant_id,
|
||||
expected_app_mode=self.target.mode,
|
||||
actor=actor,
|
||||
message_id=self.message.id,
|
||||
)
|
||||
|
||||
def assert_closed(self) -> None:
|
||||
assert all(
|
||||
not session.in_transaction() and not session.identity_map
|
||||
for session in self.provider.read_sessions + self.legacy_sessions
|
||||
)
|
||||
assert all(closed for _stage, closed in self.provider.io_sessions), self.provider.io_sessions
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> Iterator[_Harness]:
|
||||
target = App(
|
||||
id=str(uuid4()), tenant_id=str(uuid4()), name="Shared", mode=AppMode.CHAT, enable_site=True, enable_api=True
|
||||
)
|
||||
account = Account(name="Debugger", email="debugger@example.com")
|
||||
end_user = EndUser(
|
||||
tenant_id=target.tenant_id, app_id=target.id, type=EndUserType.SERVICE_API, session_id=str(uuid4())
|
||||
)
|
||||
config = AppModelConfig(
|
||||
app_id=target.id, suggested_questions_after_answer='{"enabled":true,"prompt":"Published questions"}'
|
||||
)
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add_all([target, account, end_user, config])
|
||||
session.flush()
|
||||
target.app_model_config_id = config.id
|
||||
conversation = Conversation(
|
||||
app_id=target.id,
|
||||
app_model_config_id=config.id,
|
||||
mode=AppMode.CHAT,
|
||||
name="End-user conversation",
|
||||
_inputs={},
|
||||
from_source=ConversationFromSource.API,
|
||||
from_end_user_id=end_user.id,
|
||||
)
|
||||
session.add(conversation)
|
||||
session.flush()
|
||||
message = Message(
|
||||
app_id=target.id,
|
||||
conversation_id=conversation.id,
|
||||
_inputs={},
|
||||
query="How does this work?",
|
||||
message={},
|
||||
message_unit_price=Decimal(0),
|
||||
answer="Like this.",
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
from_end_user_id=end_user.id,
|
||||
)
|
||||
session.add(message)
|
||||
|
||||
read_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
read_sessions: list[Session] = []
|
||||
|
||||
@event.listens_for(read_factory, "after_begin")
|
||||
def track_read(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
read_sessions.append(session)
|
||||
|
||||
provider = _Provider(read_sessions, target.tenant_id, target.id)
|
||||
|
||||
def model_manager(*, tenant_id: str) -> _Provider:
|
||||
assert tenant_id == target.tenant_id
|
||||
return provider
|
||||
|
||||
def trace_manager(*, app_id: str) -> _Provider:
|
||||
assert app_id == target.id
|
||||
return provider
|
||||
|
||||
monkeypatch.setattr(message_module.ModelManager, "for_tenant", model_manager)
|
||||
monkeypatch.setattr(message_module, "TraceQueueManager", trace_manager)
|
||||
flask_app = Flask(__name__)
|
||||
flask_app.config.update(TESTING=True, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
|
||||
db.init_app(flask_app)
|
||||
legacy_sessions: list[Session] = []
|
||||
|
||||
def track_legacy(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
legacy_sessions.append(session)
|
||||
|
||||
event.listen(db.session.session_factory, "after_begin", track_legacy)
|
||||
yield _Harness(
|
||||
flask_app,
|
||||
target,
|
||||
account,
|
||||
end_user,
|
||||
conversation,
|
||||
message,
|
||||
sqlite_session_factory,
|
||||
MessageSuggestedQuestionsRuntime(session_factory=read_factory),
|
||||
provider,
|
||||
legacy_sessions,
|
||||
)
|
||||
event.remove(db.session.session_factory, "after_begin", track_legacy)
|
||||
with flask_app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["app_id", "tenant", "mode", "account"])
|
||||
def test_runtime_rejects_stale_reference_before_legacy_queries(
|
||||
harness: _Harness, change: Literal["app_id", "tenant", "mode", "account"]
|
||||
) -> None:
|
||||
account_id = str(uuid4()) if change == "account" else harness.account.id
|
||||
app_id = str(uuid4()) if change == "app_id" else harness.target.id
|
||||
error_type = SuggestedQuestionsActorNotFoundError if change == "account" else AppDefinitionUnavailableError
|
||||
with pytest.raises(error_type, match=account_id if change == "account" else app_id):
|
||||
harness.runtime.get_suggested_questions(
|
||||
app_id=app_id,
|
||||
app_owner_tenant_id=str(uuid4()) if change == "tenant" else harness.target.tenant_id,
|
||||
expected_app_mode=AppMode.ADVANCED_CHAT if change == "mode" else harness.target.mode,
|
||||
actor=SuggestedQuestionsAccount(account_id=account_id, invoke_from="explore"),
|
||||
message_id=harness.message.id,
|
||||
)
|
||||
assert len(harness.provider.read_sessions) == 1
|
||||
assert not harness.legacy_sessions
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["missing", "tenant", "app"])
|
||||
def test_end_user_reload_requires_the_admitted_app_owner(
|
||||
harness: _Harness, change: Literal["missing", "tenant", "app"]
|
||||
) -> None:
|
||||
end_user_id = str(uuid4()) if change == "missing" else harness.end_user.id
|
||||
if change != "missing":
|
||||
values = {"tenant_id" if change == "tenant" else "app_id": str(uuid4())}
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(EndUser).where(EndUser.id == end_user_id).values(values))
|
||||
with pytest.raises(SuggestedQuestionsActorNotFoundError, match=end_user_id):
|
||||
harness.get(SuggestedQuestionsEndUser(end_user_id=end_user_id, invoke_from="service-api"))
|
||||
assert not harness.legacy_sessions
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invoke_from", ["debugger", "explore", "web-app", "service-api"])
|
||||
@pytest.mark.parametrize("provider_failure", [False, True])
|
||||
def test_runtime_preserves_history_configuration_credit_context_and_caller_session(
|
||||
harness: _Harness, invoke_from: _InvokeFrom, provider_failure: bool
|
||||
) -> None:
|
||||
if provider_failure:
|
||||
harness.provider.failure = RuntimeError("Provider unavailable")
|
||||
actor = harness.actor(invoke_from)
|
||||
model_config = {"provider": "vendor", "name": "question-model"}
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.id == harness.target.app_model_config_id)
|
||||
.values(
|
||||
suggested_questions_after_answer=json.dumps(
|
||||
{"enabled": True, "prompt": "Published questions", "model": model_config}
|
||||
)
|
||||
)
|
||||
)
|
||||
with harness.flask_app.app_context():
|
||||
caller_session = db.session()
|
||||
caller_app = caller_session.get(App, harness.target.id)
|
||||
assert caller_app is not None
|
||||
caller_app.name = "Pending caller edit"
|
||||
harness.legacy_sessions.clear()
|
||||
original_metadata = get_credit_usage_metadata()
|
||||
with use_credit_usage_metadata({"request_id": "suggestions-request"}):
|
||||
inherited_metadata = get_credit_usage_metadata()
|
||||
assert harness.get(actor) == ([] if provider_failure else ["What next?"])
|
||||
assert get_credit_usage_metadata() == inherited_metadata
|
||||
assert harness.provider.metadata == [
|
||||
{
|
||||
**(inherited_metadata or {}),
|
||||
"app_type": CreditUsageAppType.CHATBOT,
|
||||
"created_by": CreditUsageCreatedBy.SUGGESTED_QUESTIONS,
|
||||
}
|
||||
]
|
||||
assert get_credit_usage_metadata() == original_metadata
|
||||
assert db.session() is caller_session
|
||||
assert caller_session.in_transaction()
|
||||
assert caller_app in caller_session.dirty
|
||||
assert caller_app.name == "Pending caller edit"
|
||||
assert len(harness.provider.model_sessions) == 2
|
||||
assert harness.provider.model_sessions[0] is not harness.provider.model_sessions[1]
|
||||
assert all(session is not caller_session for session in harness.legacy_sessions)
|
||||
harness.assert_closed()
|
||||
assert len(harness.provider.prompts) == 1
|
||||
assert harness.provider.prompts[0].startswith(
|
||||
"Human: How does this work?\nAssistant: Like this.\nPublished questions\n"
|
||||
)
|
||||
assert harness.provider.model_requests == [("default", "default"), ("vendor", "question-model")]
|
||||
assert [stage for stage, _closed in harness.provider.io_sessions] == ["tokens", "invoke"]
|
||||
assert harness.provider.invocation_parameters == [{}]
|
||||
assert len(harness.provider.traces) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invoke_from", ["explore", "service-api"])
|
||||
@pytest.mark.parametrize("entity", ["message", "conversation"])
|
||||
@pytest.mark.parametrize("change", ["id", "app_id", "from_account_id", "from_end_user_id", "from_source"])
|
||||
def test_message_and_conversation_ownership_are_enforced(
|
||||
harness: _Harness,
|
||||
invoke_from: Literal["explore", "service-api"],
|
||||
entity: Literal["message", "conversation"],
|
||||
change: Literal["id", "app_id", "from_account_id", "from_end_user_id", "from_source"],
|
||||
) -> None:
|
||||
actor = harness.actor(invoke_from)
|
||||
model = Message if entity == "message" else Conversation
|
||||
record_id = harness.message.id if entity == "message" else harness.conversation.id
|
||||
if change == "from_source":
|
||||
value = ConversationFromSource.API if invoke_from == "explore" else ConversationFromSource.CONSOLE
|
||||
else:
|
||||
value = str(uuid4())
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(model).where(model.id == record_id).values({change: value}))
|
||||
error_type = MessageNotExistsError if entity == "message" else ConversationNotExistsError
|
||||
with harness.flask_app.app_context(), pytest.raises(error_type):
|
||||
harness.get(actor)
|
||||
assert not harness.provider.prompts
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invoke_from", ["explore", "web-app"])
|
||||
def test_actor_cannot_read_a_deleted_conversation(
|
||||
harness: _Harness, invoke_from: Literal["explore", "web-app"]
|
||||
) -> None:
|
||||
actor = harness.actor(invoke_from)
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(Conversation).where(Conversation.id == harness.conversation.id).values(is_deleted=True))
|
||||
with harness.flask_app.app_context(), pytest.raises(ConversationNotExistsError):
|
||||
harness.get(actor)
|
||||
assert not harness.provider.prompts
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["disabled", "missing_workflow", "history_provider_failure"])
|
||||
def test_account_preserves_disabled_feature_and_missing_workflow_or_model_behavior(
|
||||
harness: _Harness, case: Literal["disabled", "missing_workflow", "history_provider_failure"]
|
||||
) -> None:
|
||||
actor = harness.actor("explore")
|
||||
with harness.factory.begin() as session:
|
||||
if case == "disabled":
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.id == harness.target.app_model_config_id)
|
||||
.values(suggested_questions_after_answer='{"enabled":false}')
|
||||
)
|
||||
elif case == "missing_workflow":
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.ADVANCED_CHAT))
|
||||
# Admit the new mode so this covers missing workflow, not a stale reference.
|
||||
harness.target.mode = AppMode.ADVANCED_CHAT
|
||||
else:
|
||||
harness.provider.history_model_failure = True
|
||||
with harness.flask_app.app_context():
|
||||
if case == "disabled":
|
||||
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
|
||||
harness.get(actor)
|
||||
else:
|
||||
assert harness.get(actor) == []
|
||||
assert len(harness.legacy_sessions) == (1 if case == "history_provider_failure" else 0)
|
||||
assert not harness.provider.prompts
|
||||
assert not harness.provider.traces
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invoke_from", ["debugger", "explore"])
|
||||
def test_advanced_chat_debugger_uses_draft_while_explore_uses_published_workflow(
|
||||
harness: _Harness, invoke_from: Literal["debugger", "explore"]
|
||||
) -> None:
|
||||
actor = harness.actor(invoke_from)
|
||||
with harness.factory.begin() as session:
|
||||
for version, prompt in ((Workflow.VERSION_DRAFT, "Draft questions"), ("published", "Published questions")):
|
||||
workflow = Workflow(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
app_id=harness.target.id,
|
||||
type=WorkflowType.CHAT,
|
||||
version=version,
|
||||
graph='{"nodes":[],"edges":[]}',
|
||||
features=json.dumps({"suggested_questions_after_answer": {"enabled": True, "prompt": prompt}}),
|
||||
created_by=harness.account.id,
|
||||
)
|
||||
session.add(workflow)
|
||||
session.flush()
|
||||
if version != Workflow.VERSION_DRAFT:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values({App.workflow_id: workflow.id}))
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.ADVANCED_CHAT))
|
||||
session.execute(
|
||||
update(Conversation).where(Conversation.id == harness.conversation.id).values(mode=AppMode.ADVANCED_CHAT)
|
||||
)
|
||||
with harness.flask_app.app_context():
|
||||
assert harness.runtime.get_suggested_questions(
|
||||
app_id=harness.target.id,
|
||||
app_owner_tenant_id=harness.target.tenant_id,
|
||||
expected_app_mode=AppMode.ADVANCED_CHAT,
|
||||
actor=actor,
|
||||
message_id=harness.message.id,
|
||||
) == ["What next?"]
|
||||
expected_prompt = "Draft questions" if invoke_from == "debugger" else "Published questions"
|
||||
assert len(harness.provider.prompts) == 1
|
||||
assert expected_prompt in harness.provider.prompts[0]
|
||||
assert harness.provider.model_requests == [("default", "default"), ("default", "default")]
|
||||
assert [stage for stage, _closed in harness.provider.io_sessions] == ["tokens", "schema", "invoke"]
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_stage", ["tokens", "generation_model"])
|
||||
def test_runtime_releases_sessions_when_history_or_second_model_stage_fails(
|
||||
harness: _Harness, failure_stage: Literal["tokens", "generation_model"]
|
||||
) -> None:
|
||||
actor = harness.actor("explore")
|
||||
if failure_stage == "tokens":
|
||||
harness.provider.token_failure = RuntimeError("Token counter unavailable")
|
||||
else:
|
||||
harness.provider.generation_model_failure = True
|
||||
with harness.flask_app.app_context():
|
||||
if failure_stage == "tokens":
|
||||
with pytest.raises(RuntimeError, match="Token counter unavailable"):
|
||||
harness.get(actor)
|
||||
else:
|
||||
assert harness.get(actor) == []
|
||||
assert len(harness.provider.model_sessions) == (1 if failure_stage == "tokens" else 2)
|
||||
assert not harness.provider.prompts
|
||||
assert len(harness.provider.traces) == (0 if failure_stage == "tokens" else 1)
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("remote_failure", [False, True])
|
||||
def test_remote_attachment_is_loaded_after_history_and_model_sessions_close(
|
||||
harness: _Harness, monkeypatch: pytest.MonkeyPatch, remote_failure: bool
|
||||
) -> None:
|
||||
actor = harness.actor("explore")
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.id == harness.target.app_model_config_id)
|
||||
.values(
|
||||
file_upload=json.dumps(
|
||||
{"enabled": True, "allowed_file_types": ["image"], "allowed_file_upload_methods": ["remote_url"]}
|
||||
)
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
MessageFile(
|
||||
message_id=harness.message.id,
|
||||
type=FileType.IMAGE,
|
||||
transfer_method=FileTransferMethod.REMOTE_URL,
|
||||
url="https://example.com/history.png",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=harness.account.id,
|
||||
)
|
||||
)
|
||||
requests: list[str] = []
|
||||
|
||||
def fetch(method: str, url: str, **_kwargs: object) -> httpx.Response:
|
||||
harness.provider.record_io("attachment")
|
||||
requests.append(method)
|
||||
assert url == "https://example.com/history.png"
|
||||
if remote_failure:
|
||||
raise RuntimeError("Attachment unavailable")
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "image/png", "content-length": "3"},
|
||||
content=b"png",
|
||||
request=httpx.Request(method, url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(remote_fetcher, "make_request", fetch)
|
||||
with harness.flask_app.app_context():
|
||||
if remote_failure:
|
||||
with pytest.raises(RuntimeError, match="Attachment unavailable"):
|
||||
harness.get(actor)
|
||||
else:
|
||||
assert harness.get(actor) == ["What next?"]
|
||||
assert requests
|
||||
assert requests[0] == "HEAD"
|
||||
if remote_failure:
|
||||
assert not harness.provider.prompts
|
||||
assert not harness.provider.traces
|
||||
else:
|
||||
assert harness.provider.prompts[0].startswith("Human: [image]\nHow does this work?\nAssistant: Like this.")
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_stage", [None, "provider_sql", "trace"])
|
||||
def test_runtime_keeps_its_context_state_while_releasing_sessions_and_preserving_the_caller(
|
||||
harness: _Harness, monkeypatch: pytest.MonkeyPatch, failure_stage: Literal["provider_sql", "trace"] | None
|
||||
) -> None:
|
||||
actor = harness.actor("explore")
|
||||
runtime_steps: list[str] = []
|
||||
invocation_sessions: list[Session] = []
|
||||
trace_sessions: list[Session] = []
|
||||
resolve_model = harness.provider._resolve_model
|
||||
count_tokens = harness.provider.get_llm_num_tokens
|
||||
invoke_llm = harness.provider.invoke_llm
|
||||
|
||||
def resolve(*, tenant_id: str, model_type: ModelType) -> ModelInstance:
|
||||
assert g.get("caller_marker") is None
|
||||
if not runtime_steps:
|
||||
g.suggested_question_steps = runtime_steps
|
||||
assert g.get("suggested_question_steps") is runtime_steps
|
||||
runtime_steps.append("model")
|
||||
return resolve_model(tenant_id=tenant_id, model_type=model_type)
|
||||
|
||||
def tokens(prompt_messages: Sequence[PromptMessage]) -> int:
|
||||
assert g.get("suggested_question_steps") is runtime_steps
|
||||
runtime_steps.append("tokens")
|
||||
return count_tokens(prompt_messages)
|
||||
|
||||
def invoke(
|
||||
*, prompt_messages: list[PromptMessage], model_parameters: Mapping[str, object], stop: list[str], stream: bool
|
||||
) -> LLMResult:
|
||||
assert g.get("suggested_question_steps") is runtime_steps
|
||||
runtime_steps.append("invoke")
|
||||
if failure_stage == "provider_sql":
|
||||
# A real failed flush poisons this scoped session. LLMGenerator
|
||||
# catches the error, so tracing must receive a fresh session.
|
||||
session = db.session()
|
||||
invocation_sessions.append(session)
|
||||
session.add(
|
||||
App(id=harness.target.id, tenant_id=harness.target.tenant_id, name="Duplicate", mode=AppMode.CHAT)
|
||||
)
|
||||
try:
|
||||
session.flush()
|
||||
finally:
|
||||
assert not session.is_active
|
||||
return invoke_llm(prompt_messages=prompt_messages, model_parameters=model_parameters, stop=stop, stream=stream)
|
||||
|
||||
def trace(task: TraceTask) -> None:
|
||||
assert g.get("suggested_question_steps") is runtime_steps
|
||||
runtime_steps.append("trace")
|
||||
session = db.session()
|
||||
trace_sessions.append(session)
|
||||
app = session.get(App, harness.target.id)
|
||||
assert app is not None
|
||||
assert app.name == "Shared"
|
||||
if failure_stage == "trace":
|
||||
raise RuntimeError("Trace backend unavailable")
|
||||
harness.provider.traces.append(task)
|
||||
|
||||
monkeypatch.setattr(harness.provider, "_resolve_model", resolve)
|
||||
monkeypatch.setattr(harness.provider, "get_llm_num_tokens", tokens)
|
||||
monkeypatch.setattr(harness.provider, "invoke_llm", invoke)
|
||||
monkeypatch.setattr(harness.provider, "add_trace_task", trace)
|
||||
with harness.flask_app.app_context():
|
||||
caller_marker = object()
|
||||
g.caller_marker = caller_marker
|
||||
caller_session = db.session()
|
||||
caller_app = caller_session.get(App, harness.target.id)
|
||||
assert caller_app is not None
|
||||
caller_app.name = "Pending caller edit"
|
||||
harness.legacy_sessions.clear()
|
||||
if failure_stage == "trace":
|
||||
with pytest.raises(RuntimeError, match="Trace backend unavailable"):
|
||||
harness.get(actor)
|
||||
else:
|
||||
assert harness.get(actor) == ([] if failure_stage == "provider_sql" else ["What next?"])
|
||||
assert g.caller_marker is caller_marker
|
||||
assert g.get("suggested_question_steps") is None
|
||||
assert db.session() is caller_session
|
||||
assert caller_session.in_transaction()
|
||||
assert caller_app in caller_session.dirty
|
||||
assert caller_app.name == "Pending caller edit"
|
||||
assert all(session is not caller_session for session in harness.legacy_sessions)
|
||||
harness.assert_closed()
|
||||
|
||||
assert runtime_steps == ["model", "tokens", "model", "invoke", "trace"]
|
||||
assert len(trace_sessions) == 1
|
||||
assert not trace_sessions[0].in_transaction()
|
||||
if failure_stage == "provider_sql":
|
||||
assert len(invocation_sessions) == 1
|
||||
assert trace_sessions[0] is not invocation_sessions[0]
|
||||
with harness.factory() as session:
|
||||
persisted_app = session.get(App, harness.target.id)
|
||||
assert persisted_app is not None
|
||||
assert persisted_app.name == "Shared"
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Real model-resolution failures and deterministic suggested-question transformations.
|
||||
|
||||
Successful model invocation requires a configured plugin/model backend. These
|
||||
unit tests do not replace that backend or claim to cover successful LLM calls.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from flask import Flask, current_app
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import Connection, Engine, event, select
|
||||
from sqlalchemy.exc import IntegrityError, PendingRollbackError
|
||||
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
||||
from yarl import URL
|
||||
|
||||
import core.plugin.impl.base as plugin_base
|
||||
import core.plugin.impl.model_runtime as model_runtime_module
|
||||
from core.llm_generator.llm_generator import _normalize_completion_params
|
||||
from core.llm_generator.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser
|
||||
from core.llm_generator.prompts import DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
|
||||
from core.memory.token_buffer_memory import HistoryPrompt, PreparedHistory
|
||||
from core.model_context import get_credit_usage_metadata
|
||||
from core.plugin.entities.plugin_daemon import PluginDaemonInnerError
|
||||
from core.plugin.impl.base import _get_plugin_daemon_request_timeout
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models.provider import Provider, TenantDefaultModel
|
||||
from services.entities.message_entities import MessageEndUser
|
||||
from services.message_suggested_questions_generator import (
|
||||
PreparedSuggestedQuestionsModel,
|
||||
SuggestedQuestionsGenerator,
|
||||
_default_suggested_questions_model_parameters,
|
||||
)
|
||||
from services.message_suggested_questions_service import SuggestedQuestionsContext
|
||||
from tests.helpers.unit.core.model_fixtures import make_model_instance
|
||||
from tests.helpers.unit.model_factories import make_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_provider_tenant(
|
||||
_empty_model_provider_daemon: None,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> Iterator[str]:
|
||||
tenant_id = str(uuid4())
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add(Provider(tenant_id=tenant_id, provider_name="invalid/provider", is_valid=True))
|
||||
session.add(
|
||||
TenantDefaultModel(
|
||||
tenant_id=tenant_id,
|
||||
provider_name="invalid/provider",
|
||||
model_name="invalid-model",
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
)
|
||||
app = Flask(__name__)
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = str(sqlite_engine.url)
|
||||
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_pre_ping": True}
|
||||
db.init_app(app)
|
||||
with app.app_context():
|
||||
yield tenant_id
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
def test_prepare_preserves_caller_transaction_when_stored_provider_is_invalid(
|
||||
invalid_provider_tenant: str, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
context = SuggestedQuestionsContext(
|
||||
app_id=str(uuid4()),
|
||||
tenant_id=invalid_provider_tenant,
|
||||
app_mode="chat",
|
||||
message_id=str(uuid4()),
|
||||
conversation_id=str(uuid4()),
|
||||
actor=MessageEndUser(end_user_id=str(uuid4())),
|
||||
invoke_from="service-api",
|
||||
config={"enabled": True},
|
||||
)
|
||||
caller = db.session()
|
||||
provider = caller.scalars(select(Provider).where(Provider.tenant_id == invalid_provider_tenant)).one()
|
||||
provider.quota_limit = 27
|
||||
with SuggestedQuestionsGenerator().prepare(context=context, instruction_prompt=None, model_config=None) as generate:
|
||||
assert generate is None
|
||||
assert db.session() is caller
|
||||
assert caller.in_transaction()
|
||||
assert provider in caller.dirty
|
||||
assert provider.quota_limit == 27
|
||||
assert "invalid/provider" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_config",
|
||||
[None, {}, {"provider": "invalid/provider", "name": "model", "completion_params": {"temperature": 0.2}}],
|
||||
)
|
||||
def test_model_selection_returns_none_for_invalid_stored_provider(
|
||||
invalid_provider_tenant: str, model_config: object, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
metadata = get_credit_usage_metadata()
|
||||
assert SuggestedQuestionsGenerator._prepare_model(invalid_provider_tenant, model_config=model_config) is None
|
||||
assert "invalid/provider" in caplog.text
|
||||
assert get_credit_usage_metadata() == metadata
|
||||
|
||||
|
||||
def test_invocation_failure_returns_empty_questions_and_restores_request_context(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
# Use a real, uninitialized cache client, so no shared Redis mock participates.
|
||||
# The real runtime then rejects the malformed provider ID before transport I/O.
|
||||
monkeypatch.setattr(model_runtime_module, "redis_client", RedisClientWrapper())
|
||||
model = make_model_instance(provider="invalid/provider", model="unavailable")
|
||||
prepared = PreparedSuggestedQuestionsModel(model_instance=model, completion_params={"temperature": 0.2})
|
||||
metadata = get_credit_usage_metadata()
|
||||
timeout = _get_plugin_daemon_request_timeout()
|
||||
|
||||
assert SuggestedQuestionsGenerator._invoke(prepared, "Human: hello\nAssistant: world") == []
|
||||
|
||||
assert "invalid/provider" in caplog.text
|
||||
assert get_credit_usage_metadata() == metadata
|
||||
assert _get_plugin_daemon_request_timeout() == timeout
|
||||
|
||||
|
||||
def test_default_parameters_survive_real_schema_lookup_failure(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
monkeypatch.setattr(model_runtime_module, "redis_client", RedisClientWrapper())
|
||||
model = make_model_instance(provider="invalid/provider", model="unavailable")
|
||||
|
||||
assert _default_suggested_questions_model_parameters(model) == {"max_tokens": 256, "temperature": 0.0}
|
||||
assert "invalid/provider" in caplog.text
|
||||
|
||||
|
||||
def test_token_transport_failure_propagates_before_best_effort_generation(
|
||||
config_overrides: Callable[..., None], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config_overrides(PLUGIN_BASED_TOKEN_COUNTING_ENABLED=True)
|
||||
# HTTPX rejects an unsupported configured scheme itself, without a network
|
||||
# request or a replaced transport. The real plugin client wraps that error.
|
||||
monkeypatch.setattr(plugin_base, "plugin_daemon_inner_api_baseurl", URL("unsupported://plugin-daemon"))
|
||||
history = PreparedHistory(
|
||||
prompts=(
|
||||
HistoryPrompt(
|
||||
text="How does this work?",
|
||||
is_user_message=True,
|
||||
files=(),
|
||||
tenant_id=str(uuid4()),
|
||||
image_detail=ImagePromptMessageContent.DETAIL.HIGH,
|
||||
),
|
||||
)
|
||||
)
|
||||
context = SuggestedQuestionsContext(
|
||||
app_id=str(uuid4()),
|
||||
tenant_id=str(uuid4()),
|
||||
app_mode="chat",
|
||||
message_id=str(uuid4()),
|
||||
conversation_id=str(uuid4()),
|
||||
actor=MessageEndUser(end_user_id=str(uuid4())),
|
||||
invoke_from="service-api",
|
||||
config={"enabled": True},
|
||||
)
|
||||
metadata = get_credit_usage_metadata()
|
||||
timeout = _get_plugin_daemon_request_timeout()
|
||||
with pytest.raises(PluginDaemonInnerError) as failure:
|
||||
SuggestedQuestionsGenerator().generate(
|
||||
history,
|
||||
history_model=make_model_instance(provider="langgenius/openai/openai", model="history-model"),
|
||||
context=context,
|
||||
instruction_prompt=None,
|
||||
model_config=None,
|
||||
)
|
||||
assert isinstance(failure.value.__context__, httpx.UnsupportedProtocol)
|
||||
assert get_credit_usage_metadata() == metadata
|
||||
assert _get_plugin_daemon_request_timeout() == timeout
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
('["Question 1?", "还有吗?"]', ["Question 1?", "还有吗?"]),
|
||||
('Here are questions:\n["Next?"]', ["Next?"]),
|
||||
('["Next?", 1, null, true]', ["Next?"]),
|
||||
("[]", []),
|
||||
("", []),
|
||||
("not a question array", []),
|
||||
('["unfinished]', []),
|
||||
],
|
||||
)
|
||||
def test_response_parser_handles_question_arrays(text: str, expected: list[str]) -> None:
|
||||
assert SuggestedQuestionsAfterAnswerOutputParser().parse(text) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("instruction", [None, "", " "])
|
||||
def test_empty_instruction_uses_default_prompt(instruction: str | None) -> None:
|
||||
parser = SuggestedQuestionsAfterAnswerOutputParser(instruction_prompt=instruction)
|
||||
assert parser.get_format_instructions() == DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
|
||||
|
||||
|
||||
def test_custom_instruction_preserves_json_output_requirement() -> None:
|
||||
instructions = SuggestedQuestionsAfterAnswerOutputParser("Ask a follow-up").get_format_instructions()
|
||||
assert instructions.startswith("Ask a follow-up\n")
|
||||
assert 'JSON array like ["question1", "question2", "question3"]' in instructions
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [0, -1, -0.5])
|
||||
def test_non_positive_token_limits_are_removed_without_mutating_configuration(limit: int | float) -> None:
|
||||
configured: dict[str, object] = {
|
||||
"temperature": 0.2,
|
||||
"max_tokens": limit,
|
||||
"max_output_tokens": limit,
|
||||
"stop": ["END"],
|
||||
}
|
||||
parameters, stop = _normalize_completion_params(configured)
|
||||
assert parameters == {"temperature": 0.2}
|
||||
assert stop == ["END"]
|
||||
assert configured == {"temperature": 0.2, "max_tokens": limit, "max_output_tokens": limit, "stop": ["END"]}
|
||||
|
||||
|
||||
def test_configured_parameters_keep_positive_limits() -> None:
|
||||
assert _normalize_completion_params({"max_tokens": 512, "max_output_tokens": 1024}) == (
|
||||
{"max_tokens": 512, "max_output_tokens": 1024},
|
||||
[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tracing", ["invalid trace JSON", '{"enabled":{"invalid":"type"}}'])
|
||||
def test_trace_config_failure_escapes_generation_and_closes_its_sessions(
|
||||
invalid_provider_tenant: str,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
tracing: str,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
app_record = make_app(app_id=str(uuid4()), tenant_id=invalid_provider_tenant)
|
||||
app_record.tracing = tracing
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add(app_record)
|
||||
context = SuggestedQuestionsContext(
|
||||
app_id=app_record.id,
|
||||
tenant_id=invalid_provider_tenant,
|
||||
app_mode="chat",
|
||||
message_id=str(uuid4()),
|
||||
conversation_id=str(uuid4()),
|
||||
actor=MessageEndUser(end_user_id=str(uuid4())),
|
||||
invoke_from="service-api",
|
||||
config={"enabled": True},
|
||||
)
|
||||
caller = db.session()
|
||||
provider = caller.scalars(select(Provider).where(Provider.tenant_id == invalid_provider_tenant)).one()
|
||||
provider.quota_limit = 27
|
||||
transaction = caller.get_transaction()
|
||||
sessions: list[Session] = []
|
||||
|
||||
def record_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
sessions.append(session)
|
||||
|
||||
factory = db.session.session_factory
|
||||
event.listen(factory, "after_begin", record_session)
|
||||
try:
|
||||
# Empty detached history needs no token-counting call. The real failed
|
||||
# model lookup returns [], then trace construction reads the malformed
|
||||
# stored config before it could create a timer or dispatch telemetry.
|
||||
with pytest.raises(ValidationError), current_app.app_context():
|
||||
SuggestedQuestionsGenerator().generate(
|
||||
PreparedHistory(prompts=()),
|
||||
history_model=make_model_instance(provider="invalid/provider", model="unused"),
|
||||
context=context,
|
||||
instruction_prompt=None,
|
||||
model_config=None,
|
||||
)
|
||||
finally:
|
||||
event.remove(factory, "after_begin", record_session)
|
||||
|
||||
assert "invalid/provider" in caplog.text
|
||||
assert len(sessions) == 2
|
||||
assert sessions[0] is not sessions[1]
|
||||
assert all(not session.in_transaction() and not session.identity_map for session in sessions)
|
||||
assert db.session() is caller
|
||||
assert caller.get_transaction() is transaction
|
||||
assert provider in caller.dirty
|
||||
assert provider.quota_limit == 27
|
||||
with sqlite_session_factory() as session:
|
||||
persisted = session.get(Provider, provider.id)
|
||||
assert persisted is not None
|
||||
assert persisted.quota_limit is None
|
||||
|
||||
|
||||
def test_prepare_isolates_a_caller_transaction_failed_by_a_real_constraint(
|
||||
invalid_provider_tenant: str, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
context = SuggestedQuestionsContext(
|
||||
app_id=str(uuid4()),
|
||||
tenant_id=invalid_provider_tenant,
|
||||
app_mode="chat",
|
||||
message_id=str(uuid4()),
|
||||
conversation_id=str(uuid4()),
|
||||
actor=MessageEndUser(end_user_id=str(uuid4())),
|
||||
invoke_from="service-api",
|
||||
config={"enabled": True},
|
||||
)
|
||||
caller = db.session()
|
||||
provider_id = caller.scalars(select(Provider.id).where(Provider.tenant_id == invalid_provider_tenant)).one()
|
||||
duplicate = Provider(tenant_id=invalid_provider_tenant, provider_name="duplicate", is_valid=True)
|
||||
duplicate.id = provider_id
|
||||
caller.add(duplicate)
|
||||
with pytest.raises(IntegrityError):
|
||||
caller.flush()
|
||||
failed_transaction = caller.get_transaction()
|
||||
assert failed_transaction is not None
|
||||
assert not caller.is_active
|
||||
|
||||
with SuggestedQuestionsGenerator().prepare(context=context, instruction_prompt=None, model_config=None) as generate:
|
||||
assert generate is None
|
||||
|
||||
# Model resolution used an independent usable session. It must neither
|
||||
# inherit the caller's failed transaction nor roll that transaction back.
|
||||
assert "invalid/provider" in caplog.text
|
||||
assert db.session() is caller
|
||||
assert caller.get_transaction() is failed_transaction
|
||||
assert not caller.is_active
|
||||
with pytest.raises(PendingRollbackError):
|
||||
caller.scalar(select(Provider.id))
|
||||
caller.rollback()
|
||||
assert caller.scalar(select(Provider.id).where(Provider.id == provider_id)) == provider_id
|
||||
@@ -0,0 +1,834 @@
|
||||
"""Shared service ownership, configuration selection, and session lifecycle."""
|
||||
|
||||
import json
|
||||
from base64 import b64decode
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Connection, Engine, event, update
|
||||
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from core.app.file_access import FileAccessScope, bind_file_access_scope
|
||||
from core.memory.token_buffer_memory import PreparedHistory
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_storage import storage
|
||||
from extensions.storage.opendal_storage import OpenDALStorage
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from graphon.file import FileTransferMethod, FileType
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models import Account, App, AppMode, Conversation, Message
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigSnapshot,
|
||||
AgentConfigVersionKind,
|
||||
AgentDebugConversation,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentWorkspaceBinding,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import ConversationFromSource, CreatorUserRole, EndUserType
|
||||
from models.model import AppModelConfig, EndUser, MessageFile, UploadFile
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
from repositories.message_repository import MessageRepository
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.entities.message_entities import MessageAccount, MessageActor, MessageEndUser
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import (
|
||||
MessageActorNotFoundError,
|
||||
MessageNotExistsError,
|
||||
SuggestedQuestionsAfterAnswerDisabledError,
|
||||
)
|
||||
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
|
||||
from services.message_suggested_questions_queries import SuggestedQuestionsQuery
|
||||
from services.message_suggested_questions_service import (
|
||||
MessageSuggestedQuestionsService,
|
||||
SuggestedQuestionsContext,
|
||||
SuggestedQuestionsInvokeFrom,
|
||||
)
|
||||
from tests.helpers.unit.core.model_fixtures import make_model_instance
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Harness:
|
||||
flask_app: Flask
|
||||
target: App
|
||||
account: Account
|
||||
end_user: EndUser
|
||||
conversation: Conversation
|
||||
message: Message
|
||||
factory: sessionmaker[Session]
|
||||
queries: SuggestedQuestionsQuery
|
||||
service: MessageSuggestedQuestionsService[PreparedHistory]
|
||||
read_sessions: list[Session]
|
||||
scoped_sessions: list[Session]
|
||||
|
||||
def actor(self, invoke_from: SuggestedQuestionsInvokeFrom) -> MessageActor:
|
||||
if invoke_from in {"explore", "debugger"}:
|
||||
with self.factory.begin() as session:
|
||||
for model, record_id in ((Message, self.message.id), (Conversation, self.conversation.id)):
|
||||
session.execute(
|
||||
update(model)
|
||||
.where(model.id == record_id)
|
||||
.values(
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=self.account.id,
|
||||
from_end_user_id=None,
|
||||
)
|
||||
)
|
||||
return MessageAccount(account_id=self.account.id)
|
||||
return MessageEndUser(end_user_id=self.end_user.id)
|
||||
|
||||
def get(self, actor: MessageActor, *, invoke_from: SuggestedQuestionsInvokeFrom) -> list[str]:
|
||||
return self.service.get_suggested_questions(
|
||||
app_id=self.target.id,
|
||||
app_owner_tenant_id=self.target.tenant_id,
|
||||
expected_app_mode=self.target.mode,
|
||||
actor=actor,
|
||||
invoke_from=invoke_from,
|
||||
message_id=self.message.id,
|
||||
)
|
||||
|
||||
def prepare(self, actor: MessageActor, *, invoke_from: SuggestedQuestionsInvokeFrom) -> SuggestedQuestionsContext:
|
||||
context = self.queries.prepare(
|
||||
app_id=self.target.id,
|
||||
app_owner_tenant_id=self.target.tenant_id,
|
||||
expected_app_mode=self.target.mode,
|
||||
actor=actor,
|
||||
invoke_from=invoke_from,
|
||||
message_id=self.message.id,
|
||||
)
|
||||
assert context is not None
|
||||
return context
|
||||
|
||||
def set_mode(self, mode: AppMode) -> None:
|
||||
with self.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == self.target.id).values(mode=mode))
|
||||
session.execute(update(Conversation).where(Conversation.id == self.conversation.id).values(mode=mode))
|
||||
self.target.mode = mode
|
||||
|
||||
def assert_closed(self) -> None:
|
||||
assert all(
|
||||
not session.in_transaction() and not session.identity_map
|
||||
for session in self.read_sessions + self.scoped_sessions
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> Iterator[_Harness]:
|
||||
target = App(
|
||||
id=str(uuid4()), tenant_id=str(uuid4()), name="Shared", mode=AppMode.CHAT, enable_site=True, enable_api=True
|
||||
)
|
||||
account = Account(name="Debugger", email="debugger@example.com")
|
||||
end_user = EndUser(
|
||||
tenant_id=target.tenant_id, app_id=target.id, type=EndUserType.SERVICE_API, session_id=str(uuid4())
|
||||
)
|
||||
config = AppModelConfig(
|
||||
app_id=target.id, suggested_questions_after_answer='{"enabled":true,"prompt":"Published questions"}'
|
||||
)
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add_all([target, account, end_user, config])
|
||||
session.flush()
|
||||
target.app_model_config_id = config.id
|
||||
conversation = Conversation(
|
||||
app_id=target.id,
|
||||
app_model_config_id=config.id,
|
||||
mode=AppMode.CHAT,
|
||||
name="End-user conversation",
|
||||
_inputs={},
|
||||
from_source=ConversationFromSource.API,
|
||||
from_end_user_id=end_user.id,
|
||||
)
|
||||
session.add(conversation)
|
||||
session.flush()
|
||||
message = Message(
|
||||
app_id=target.id,
|
||||
conversation_id=conversation.id,
|
||||
_inputs={},
|
||||
query="How does this work?",
|
||||
message={},
|
||||
message_unit_price=Decimal(0),
|
||||
answer="Like this.",
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
from_end_user_id=end_user.id,
|
||||
)
|
||||
session.add(message)
|
||||
|
||||
read_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
read_sessions: list[Session] = []
|
||||
|
||||
@event.listens_for(read_factory, "after_begin")
|
||||
def track_read(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
read_sessions.append(session)
|
||||
|
||||
flask_app = Flask(__name__)
|
||||
flask_app.config.update(TESTING=True, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
|
||||
db.init_app(flask_app)
|
||||
scoped_sessions: list[Session] = []
|
||||
|
||||
def track_scoped(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
scoped_sessions.append(session)
|
||||
|
||||
event.listen(db.session.session_factory, "after_begin", track_scoped)
|
||||
queries = SuggestedQuestionsQuery(session_factory=read_factory, repository_factory=MessageRepository)
|
||||
with flask_app.app_context():
|
||||
yield _Harness(
|
||||
flask_app,
|
||||
target,
|
||||
account,
|
||||
end_user,
|
||||
conversation,
|
||||
message,
|
||||
sqlite_session_factory,
|
||||
queries,
|
||||
MessageSuggestedQuestionsService(queries=queries, generator=SuggestedQuestionsGenerator()),
|
||||
read_sessions,
|
||||
scoped_sessions,
|
||||
)
|
||||
event.remove(db.session.session_factory, "after_begin", track_scoped)
|
||||
with flask_app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["app_id", "tenant", "mode", "account"])
|
||||
def test_service_rejects_stale_reference_before_model_queries(
|
||||
harness: _Harness, change: Literal["app_id", "tenant", "mode", "account"]
|
||||
) -> None:
|
||||
account_id = str(uuid4()) if change == "account" else harness.account.id
|
||||
app_id = str(uuid4()) if change == "app_id" else harness.target.id
|
||||
error_type = MessageActorNotFoundError if change == "account" else AppDefinitionUnavailableError
|
||||
with pytest.raises(error_type, match=account_id if change == "account" else app_id):
|
||||
harness.service.get_suggested_questions(
|
||||
app_id=app_id,
|
||||
app_owner_tenant_id=str(uuid4()) if change == "tenant" else harness.target.tenant_id,
|
||||
expected_app_mode=AppMode.ADVANCED_CHAT if change == "mode" else harness.target.mode,
|
||||
actor=MessageAccount(account_id=account_id),
|
||||
invoke_from="explore",
|
||||
message_id=harness.message.id,
|
||||
)
|
||||
assert len(harness.read_sessions) == 1
|
||||
assert not harness.scoped_sessions
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["missing", "tenant", "app"])
|
||||
def test_end_user_reload_requires_the_admitted_app_owner(
|
||||
harness: _Harness, change: Literal["missing", "tenant", "app"]
|
||||
) -> None:
|
||||
end_user_id = str(uuid4()) if change == "missing" else harness.end_user.id
|
||||
if change != "missing":
|
||||
values = {"tenant_id" if change == "tenant" else "app_id": str(uuid4())}
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(EndUser).where(EndUser.id == end_user_id).values(values))
|
||||
with pytest.raises(MessageActorNotFoundError, match=end_user_id):
|
||||
harness.get(MessageEndUser(end_user_id=end_user_id), invoke_from="service-api")
|
||||
assert not harness.scoped_sessions
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invoke_from", ["debugger", "explore", "web-app", "service-api"])
|
||||
def test_queries_return_detached_configuration_and_history_without_changing_caller_session(
|
||||
harness: _Harness, invoke_from: SuggestedQuestionsInvokeFrom, config_overrides: Callable[..., None]
|
||||
) -> None:
|
||||
# Exercise the production path for deployments with plugin token counting disabled.
|
||||
config_overrides(PLUGIN_BASED_TOKEN_COUNTING_ENABLED=False)
|
||||
actor = harness.actor(invoke_from)
|
||||
feature = {
|
||||
"enabled": True,
|
||||
"prompt": "Published questions",
|
||||
"model": {"provider": "vendor", "name": "question-model"},
|
||||
}
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.id == harness.target.app_model_config_id)
|
||||
.values(suggested_questions_after_answer=json.dumps(feature))
|
||||
)
|
||||
with harness.flask_app.app_context():
|
||||
caller_session = db.session()
|
||||
caller_app = caller_session.get(App, harness.target.id)
|
||||
assert caller_app is not None
|
||||
caller_app.name = "Pending caller edit"
|
||||
harness.scoped_sessions.clear()
|
||||
|
||||
context = harness.prepare(actor, invoke_from=invoke_from)
|
||||
assert context.app_id == harness.target.id
|
||||
assert context.tenant_id == harness.target.tenant_id
|
||||
assert context.app_mode == AppMode.CHAT
|
||||
assert context.message_id == harness.message.id
|
||||
assert context.conversation_id == harness.conversation.id
|
||||
assert context.actor == actor
|
||||
assert context.invoke_from == invoke_from
|
||||
assert context.config == feature
|
||||
assert len(harness.read_sessions) == 1
|
||||
harness.assert_closed()
|
||||
|
||||
history = harness.queries.load_history(context)
|
||||
assert [(prompt.text, prompt.is_user_message) for prompt in history.prompts] == [
|
||||
("How does this work?", True),
|
||||
("Like this.", False),
|
||||
]
|
||||
assert all(not prompt.files for prompt in history.prompts)
|
||||
assert all(prompt.tenant_id == harness.target.tenant_id for prompt in history.prompts)
|
||||
assert len(harness.read_sessions) == 2
|
||||
assert harness.read_sessions[0] is not harness.read_sessions[1]
|
||||
harness.assert_closed()
|
||||
model = make_model_instance(provider="langgenius/openai/openai", model="history-model")
|
||||
assert history.get_prompt_text(model_instance=model, max_token_limit=3000) == (
|
||||
"Human: How does this work?\nAssistant: Like this."
|
||||
)
|
||||
harness.assert_closed()
|
||||
|
||||
assert db.session() is caller_session
|
||||
assert caller_session.in_transaction()
|
||||
assert caller_app in caller_session.dirty
|
||||
assert caller_app.name == "Pending caller edit"
|
||||
assert not harness.scoped_sessions
|
||||
|
||||
# The query did not commit the caller's pending edit, and the returned
|
||||
# configuration and history no longer depend on the persisted rows.
|
||||
with harness.factory.begin() as session:
|
||||
persisted_app = session.get(App, harness.target.id)
|
||||
assert persisted_app is not None
|
||||
assert persisted_app.name == "Shared"
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.id == harness.target.app_model_config_id)
|
||||
.values(suggested_questions_after_answer='{"enabled":false}')
|
||||
)
|
||||
session.execute(update(Message).where(Message.id == harness.message.id).values(answer="Changed answer"))
|
||||
assert context.config == feature
|
||||
assert history.prompts[-1].text == "Like this."
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invoke_from", ["explore", "service-api"])
|
||||
@pytest.mark.parametrize("entity", ["message", "conversation"])
|
||||
@pytest.mark.parametrize("change", ["id", "app_id", "from_account_id", "from_end_user_id", "from_source"])
|
||||
def test_message_and_conversation_ownership_are_enforced(
|
||||
harness: _Harness,
|
||||
invoke_from: Literal["explore", "service-api"],
|
||||
entity: Literal["message", "conversation"],
|
||||
change: Literal["id", "app_id", "from_account_id", "from_end_user_id", "from_source"],
|
||||
) -> None:
|
||||
actor = harness.actor(invoke_from)
|
||||
model = Message if entity == "message" else Conversation
|
||||
record_id = harness.message.id if entity == "message" else harness.conversation.id
|
||||
if change == "from_source":
|
||||
value = ConversationFromSource.API if invoke_from == "explore" else ConversationFromSource.CONSOLE
|
||||
else:
|
||||
value = str(uuid4())
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(model).where(model.id == record_id).values({change: value}))
|
||||
error_type = MessageNotExistsError if entity == "message" else ConversationNotExistsError
|
||||
with harness.flask_app.app_context(), pytest.raises(error_type):
|
||||
harness.get(actor, invoke_from=invoke_from)
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invoke_from", ["explore", "web-app"])
|
||||
def test_actor_cannot_read_a_deleted_conversation(
|
||||
harness: _Harness, invoke_from: Literal["explore", "web-app"]
|
||||
) -> None:
|
||||
actor = harness.actor(invoke_from)
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(Conversation).where(Conversation.id == harness.conversation.id).values(is_deleted=True))
|
||||
with harness.flask_app.app_context(), pytest.raises(ConversationNotExistsError):
|
||||
harness.get(actor, invoke_from=invoke_from)
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invoke_from", ["explore", "service-api"])
|
||||
@pytest.mark.parametrize(
|
||||
"change", ["tenant", "mode", "app_id", "from_account_id", "from_end_user_id", "from_source", "deleted"]
|
||||
)
|
||||
def test_history_revalidates_app_and_conversation_after_preparing_context(
|
||||
harness: _Harness,
|
||||
invoke_from: Literal["explore", "service-api"],
|
||||
change: Literal["tenant", "mode", "app_id", "from_account_id", "from_end_user_id", "from_source", "deleted"],
|
||||
) -> None:
|
||||
context = harness.queries.prepare(
|
||||
app_id=harness.target.id,
|
||||
app_owner_tenant_id=harness.target.tenant_id,
|
||||
expected_app_mode=harness.target.mode,
|
||||
actor=harness.actor(invoke_from),
|
||||
invoke_from=invoke_from,
|
||||
message_id=harness.message.id,
|
||||
)
|
||||
assert context is not None
|
||||
harness.assert_closed()
|
||||
|
||||
with harness.factory.begin() as session:
|
||||
if change in {"tenant", "mode"}:
|
||||
values = {"tenant_id": str(uuid4())} if change == "tenant" else {"mode": AppMode.ADVANCED_CHAT}
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(values))
|
||||
else:
|
||||
conversation_values: dict[str, object]
|
||||
if change == "deleted":
|
||||
conversation_values = {"is_deleted": True}
|
||||
elif change == "from_source":
|
||||
conversation_values = {
|
||||
"from_source": ConversationFromSource.API
|
||||
if invoke_from == "explore"
|
||||
else ConversationFromSource.CONSOLE
|
||||
}
|
||||
else:
|
||||
conversation_values = {change: str(uuid4())}
|
||||
session.execute(
|
||||
update(Conversation).where(Conversation.id == harness.conversation.id).values(conversation_values)
|
||||
)
|
||||
|
||||
error_type = AppDefinitionUnavailableError if change in {"tenant", "mode"} else ConversationNotExistsError
|
||||
with pytest.raises(error_type):
|
||||
harness.queries.load_history(context)
|
||||
assert len(harness.read_sessions) == 2
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["disabled", "missing_workflow"])
|
||||
def test_account_preserves_disabled_feature_and_missing_workflow_behavior(
|
||||
harness: _Harness, case: Literal["disabled", "missing_workflow"]
|
||||
) -> None:
|
||||
actor = harness.actor("explore")
|
||||
with harness.factory.begin() as session:
|
||||
if case == "disabled":
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.id == harness.target.app_model_config_id)
|
||||
.values(suggested_questions_after_answer='{"enabled":false}')
|
||||
)
|
||||
else:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.ADVANCED_CHAT))
|
||||
harness.target.mode = AppMode.ADVANCED_CHAT
|
||||
if case == "disabled":
|
||||
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
|
||||
harness.get(actor, invoke_from="explore")
|
||||
else:
|
||||
assert harness.get(actor, invoke_from="explore") == []
|
||||
# Both branches finish before model resolution and the separate history query.
|
||||
assert not harness.scoped_sessions
|
||||
assert len(harness.read_sessions) == 1
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invoke_from", ["debugger", "explore"])
|
||||
def test_advanced_chat_debugger_uses_draft_while_explore_uses_published_workflow(
|
||||
harness: _Harness, invoke_from: Literal["debugger", "explore"]
|
||||
) -> None:
|
||||
actor = harness.actor(invoke_from)
|
||||
with harness.factory.begin() as session:
|
||||
for version, prompt in ((Workflow.VERSION_DRAFT, "Draft questions"), ("published", "Published questions")):
|
||||
workflow = Workflow(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
app_id=harness.target.id,
|
||||
type=WorkflowType.CHAT,
|
||||
version=version,
|
||||
graph='{"nodes":[],"edges":[]}',
|
||||
features=json.dumps({"suggested_questions_after_answer": {"enabled": True, "prompt": prompt}}),
|
||||
created_by=harness.account.id,
|
||||
)
|
||||
session.add(workflow)
|
||||
session.flush()
|
||||
if version != Workflow.VERSION_DRAFT:
|
||||
session.execute(update(App).where(App.id == harness.target.id).values({App.workflow_id: workflow.id}))
|
||||
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.ADVANCED_CHAT))
|
||||
session.execute(
|
||||
update(Conversation).where(Conversation.id == harness.conversation.id).values(mode=AppMode.ADVANCED_CHAT)
|
||||
)
|
||||
harness.target.mode = AppMode.ADVANCED_CHAT
|
||||
context = harness.prepare(actor, invoke_from=invoke_from)
|
||||
expected_prompt = "Draft questions" if invoke_from == "debugger" else "Published questions"
|
||||
assert context.config == {"enabled": True, "prompt": expected_prompt}
|
||||
assert context.app_mode == AppMode.ADVANCED_CHAT
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
def test_history_detaches_remote_attachment_references_before_rendering(harness: _Harness) -> None:
|
||||
actor = harness.actor("explore")
|
||||
attachment = MessageFile(
|
||||
message_id=harness.message.id,
|
||||
type=FileType.IMAGE,
|
||||
transfer_method=FileTransferMethod.REMOTE_URL,
|
||||
url="https://example.com/history.png",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=harness.account.id,
|
||||
)
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.id == harness.target.app_model_config_id)
|
||||
.values(
|
||||
file_upload=json.dumps(
|
||||
{"enabled": True, "allowed_file_types": ["image"], "allowed_file_upload_methods": ["remote_url"]}
|
||||
)
|
||||
)
|
||||
)
|
||||
session.add(attachment)
|
||||
context = harness.prepare(actor, invoke_from="explore")
|
||||
history = harness.queries.load_history(context)
|
||||
harness.assert_closed()
|
||||
assert len(harness.read_sessions) == 2
|
||||
user_prompt, assistant_prompt = history.prompts
|
||||
assert user_prompt.text == "How does this work?"
|
||||
assert user_prompt.tenant_id == harness.target.tenant_id
|
||||
assert len(user_prompt.files) == 1
|
||||
reference = user_prompt.files[0]
|
||||
assert reference.id == attachment.id
|
||||
assert reference.type == FileType.IMAGE
|
||||
assert reference.transfer_method == FileTransferMethod.REMOTE_URL
|
||||
assert reference.url == attachment.url
|
||||
assert reference.upload_file_id is None
|
||||
assert not assistant_prompt.files
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["missing-record", "other-owner", "missing-content", "available"])
|
||||
def test_attachment_restoration_closes_sessions_for_real_file_failures(
|
||||
harness: _Harness,
|
||||
case: Literal["missing-record", "other-owner", "missing-content", "available"],
|
||||
config_overrides: Callable[..., None],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config_overrides(PLUGIN_BASED_TOKEN_COUNTING_ENABLED=False, MULTIMODAL_SEND_FORMAT="base64")
|
||||
local_storage = OpenDALStorage(scheme="fs", root=str(tmp_path))
|
||||
# Bind an actual filesystem backend; file building, authorization and reads
|
||||
# all keep their production implementations.
|
||||
monkeypatch.setattr(storage, "storage_runner", local_storage, raising=False)
|
||||
image = b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+xU5sAAAAASUVORK5CYII=")
|
||||
upload = UploadFile(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
storage_type=StorageType.LOCAL,
|
||||
key="history/image.png",
|
||||
name="image.png",
|
||||
size=len(image),
|
||||
extension="png",
|
||||
mime_type="image/png",
|
||||
created_by_role=CreatorUserRole.END_USER,
|
||||
created_by=str(uuid4()) if case == "other-owner" else harness.end_user.id,
|
||||
created_at=naive_utc_now(),
|
||||
used=True,
|
||||
)
|
||||
if case != "missing-content":
|
||||
local_storage.save(upload.key, image)
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.id == harness.target.app_model_config_id)
|
||||
.values(
|
||||
file_upload=json.dumps(
|
||||
{"enabled": True, "allowed_file_types": ["image"], "allowed_file_upload_methods": ["local_file"]}
|
||||
)
|
||||
)
|
||||
)
|
||||
if case != "missing-record":
|
||||
session.add(upload)
|
||||
session.add(
|
||||
MessageFile(
|
||||
message_id=harness.message.id,
|
||||
type=FileType.IMAGE,
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
upload_file_id=upload.id,
|
||||
created_by_role=CreatorUserRole.END_USER,
|
||||
created_by=harness.end_user.id,
|
||||
)
|
||||
)
|
||||
context = harness.prepare(harness.actor("web-app"), invoke_from="web-app")
|
||||
history = harness.queries.load_history(context)
|
||||
assert len(harness.read_sessions) == 2
|
||||
harness.assert_closed()
|
||||
model = make_model_instance(provider="langgenius/openai/openai", model="history-model")
|
||||
file_sessions: list[Session] = []
|
||||
|
||||
def track_file_read(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
harness.assert_closed()
|
||||
file_sessions.append(session)
|
||||
|
||||
scope = FileAccessScope(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
user_id=harness.end_user.id,
|
||||
user_from=UserFrom.END_USER,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
event.listen(harness.factory, "after_begin", track_file_read)
|
||||
try:
|
||||
with bind_file_access_scope(scope):
|
||||
if case == "available":
|
||||
assert history.get_prompt_text(model_instance=model, max_token_limit=3000) == (
|
||||
"Human: [image]\nHow does this work?\nAssistant: Like this."
|
||||
)
|
||||
else:
|
||||
error = FileNotFoundError if case == "missing-content" else ValueError
|
||||
detail = "File not found" if case == "missing-content" else "Invalid upload file"
|
||||
with pytest.raises(error, match=detail):
|
||||
history.get_prompt_text(model_instance=model, max_token_limit=3000)
|
||||
finally:
|
||||
event.remove(harness.factory, "after_begin", track_file_read)
|
||||
assert len(file_sessions) == (2 if case in {"missing-content", "available"} else 1)
|
||||
assert all(not session.in_transaction() and not session.identity_map for session in file_sessions)
|
||||
assert not harness.scoped_sessions
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
def test_configuration_error_closes_query_session_and_preserves_caller_transaction(harness: _Harness) -> None:
|
||||
actor = harness.actor("explore")
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.id == harness.target.app_model_config_id)
|
||||
.values(suggested_questions_after_answer="{")
|
||||
)
|
||||
with harness.flask_app.app_context():
|
||||
caller_session = db.session()
|
||||
caller_app = caller_session.get(App, harness.target.id)
|
||||
assert caller_app is not None
|
||||
caller_app.name = "Pending caller edit"
|
||||
harness.scoped_sessions.clear()
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
harness.get(actor, invoke_from="explore")
|
||||
assert len(harness.read_sessions) == 1
|
||||
harness.assert_closed()
|
||||
assert db.session() is caller_session
|
||||
assert caller_session.in_transaction()
|
||||
assert caller_app in caller_session.dirty
|
||||
assert caller_app.name == "Pending caller edit"
|
||||
assert not harness.scoped_sessions
|
||||
with harness.factory() as session:
|
||||
persisted_app = session.get(App, harness.target.id)
|
||||
assert persisted_app is not None
|
||||
assert persisted_app.name == "Shared"
|
||||
|
||||
|
||||
def _agent_soul(prompt: str, *, enabled: bool = True, model: dict[str, object] | None = None) -> AgentSoulConfig:
|
||||
feature: dict[str, object] = {"enabled": enabled, "prompt": prompt}
|
||||
if model is not None:
|
||||
feature["model"] = model
|
||||
return AgentSoulConfig.model_validate({"app_features": {"suggested_questions_after_answer": feature}})
|
||||
|
||||
|
||||
def _published_agent(harness: _Harness, *, enabled: bool = True) -> Agent:
|
||||
agent = Agent(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
app_id=harness.target.id,
|
||||
name="Published agent",
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
)
|
||||
with harness.factory.begin() as session:
|
||||
session.add(agent)
|
||||
session.flush()
|
||||
snapshot = AgentConfigSnapshot(
|
||||
tenant_id=agent.tenant_id,
|
||||
agent_id=agent.id,
|
||||
version=1,
|
||||
config_snapshot=_agent_soul("Current agent questions", enabled=enabled),
|
||||
)
|
||||
session.add(snapshot)
|
||||
session.flush()
|
||||
agent.active_config_snapshot_id = snapshot.id
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.mark.parametrize("draft_type", [AgentConfigDraftType.DRAFT, AgentConfigDraftType.DEBUG_BUILD])
|
||||
def test_agent_debugger_uses_matching_draft_with_custom_model(
|
||||
harness: _Harness, draft_type: AgentConfigDraftType
|
||||
) -> None:
|
||||
harness.set_mode(AppMode.AGENT)
|
||||
actor = harness.actor("debugger")
|
||||
agent = _published_agent(harness)
|
||||
model: dict[str, object] = {"provider": "vendor", "name": "draft-model", "completion_params": {"temperature": 0.1}}
|
||||
with harness.factory.begin() as session:
|
||||
session.add_all(
|
||||
[
|
||||
AgentConfigDraft(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=draft_type,
|
||||
account_id=harness.account.id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None,
|
||||
draft_owner_key=harness.account.id if draft_type == AgentConfigDraftType.DEBUG_BUILD else "",
|
||||
config_snapshot=_agent_soul("Matching draft questions", model=model),
|
||||
),
|
||||
AgentDebugConversation(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
agent_id=agent.id,
|
||||
app_id=harness.target.id,
|
||||
account_id=harness.account.id,
|
||||
draft_type=draft_type,
|
||||
conversation_id=harness.conversation.id,
|
||||
),
|
||||
]
|
||||
)
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.id == harness.target.app_model_config_id)
|
||||
.values(suggested_questions_after_answer='{"enabled":false}')
|
||||
)
|
||||
context = harness.prepare(actor, invoke_from="debugger")
|
||||
assert context.config == {"enabled": True, "prompt": "Matching draft questions", "model": model}
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version_kind", [AgentConfigVersionKind.SNAPSHOT, AgentConfigVersionKind.DRAFT])
|
||||
def test_agent_conversation_uses_bound_config_before_current_published_config(
|
||||
harness: _Harness, version_kind: AgentConfigVersionKind
|
||||
) -> None:
|
||||
harness.set_mode(AppMode.AGENT)
|
||||
agent = _published_agent(harness)
|
||||
with harness.factory.begin() as session:
|
||||
config: AgentConfigSnapshot | AgentConfigDraft
|
||||
if version_kind == AgentConfigVersionKind.SNAPSHOT:
|
||||
config = AgentConfigSnapshot(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
agent_id=agent.id,
|
||||
version=2,
|
||||
config_snapshot=_agent_soul("Bound agent questions"),
|
||||
)
|
||||
else:
|
||||
config = AgentConfigDraft(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
config_snapshot=_agent_soul("Bound agent questions"),
|
||||
)
|
||||
session.add(config)
|
||||
session.flush()
|
||||
binding = AgentWorkspaceBinding(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
app_id=harness.target.id,
|
||||
workspace_id=str(uuid4()),
|
||||
agent_id=agent.id,
|
||||
agent_config_version_id=config.id,
|
||||
agent_config_version_kind=version_kind,
|
||||
backend_binding_ref="bound-agent",
|
||||
)
|
||||
session.add(binding)
|
||||
session.flush()
|
||||
session.execute(
|
||||
update(Conversation)
|
||||
.where(Conversation.id == harness.conversation.id)
|
||||
.values(agent_workspace_binding_id=binding.id)
|
||||
)
|
||||
context = harness.prepare(harness.actor("service-api"), invoke_from="service-api")
|
||||
assert context.config == {"enabled": True, "prompt": "Bound agent questions"}
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["published", "legacy", "disabled"])
|
||||
def test_agent_published_config_and_legacy_fallback(
|
||||
harness: _Harness, case: Literal["published", "legacy", "disabled"]
|
||||
) -> None:
|
||||
harness.set_mode(AppMode.AGENT)
|
||||
if case != "legacy":
|
||||
_published_agent(harness, enabled=case != "disabled")
|
||||
actor = harness.actor("service-api")
|
||||
if case == "disabled":
|
||||
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
|
||||
harness.get(actor, invoke_from="service-api")
|
||||
else:
|
||||
context = harness.prepare(actor, invoke_from="service-api")
|
||||
expected_prompt = "Published questions" if case == "legacy" else "Current agent questions"
|
||||
assert context.config == {"enabled": True, "prompt": expected_prompt}
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_override", [False, True])
|
||||
def test_chat_uses_conversation_config_and_custom_completion_parameters(harness: _Harness, use_override: bool) -> None:
|
||||
feature = {
|
||||
"enabled": True,
|
||||
"prompt": "Conversation questions",
|
||||
"model": {"provider": "vendor", "name": "conversation-model", "completion_params": {"temperature": 0.2}},
|
||||
}
|
||||
with harness.factory.begin() as session:
|
||||
if use_override:
|
||||
override = {
|
||||
"model": {"provider": "vendor", "name": "chat-model", "mode": "chat"},
|
||||
"suggested_questions_after_answer": feature,
|
||||
}
|
||||
session.execute(
|
||||
update(Conversation)
|
||||
.where(Conversation.id == harness.conversation.id)
|
||||
.values({Conversation.override_model_configs: json.dumps(override)})
|
||||
)
|
||||
else:
|
||||
session.execute(
|
||||
update(AppModelConfig)
|
||||
.where(AppModelConfig.id == harness.target.app_model_config_id)
|
||||
.values(suggested_questions_after_answer=json.dumps(feature))
|
||||
)
|
||||
current_config = AppModelConfig(app_id=harness.target.id, suggested_questions_after_answer='{"enabled":false}')
|
||||
session.add(current_config)
|
||||
session.flush()
|
||||
session.execute(
|
||||
update(App).where(App.id == harness.target.id).values({App.app_model_config_id: current_config.id})
|
||||
)
|
||||
context = harness.prepare(harness.actor("web-app"), invoke_from="web-app")
|
||||
assert context.config == feature
|
||||
harness.assert_closed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"file_upload",
|
||||
[
|
||||
None,
|
||||
{"image": None},
|
||||
{"image": {"enabled": False}},
|
||||
{"image": {"enabled": True, "number_limits": 3, "transfer_methods": ["remote_url"]}},
|
||||
],
|
||||
ids=["null-upload", "null-image", "disabled-image", "legacy-image"],
|
||||
)
|
||||
def test_workflow_compatibility_normalization_does_not_dirty_or_write_rows(
|
||||
harness: _Harness, file_upload: dict[str, object] | None
|
||||
) -> None:
|
||||
harness.set_mode(AppMode.ADVANCED_CHAT)
|
||||
actor = harness.actor("explore")
|
||||
features = json.dumps(
|
||||
{
|
||||
"suggested_questions_after_answer": {"enabled": True, "prompt": "Legacy workflow questions"},
|
||||
"file_upload": file_upload,
|
||||
}
|
||||
)
|
||||
with harness.factory.begin() as session:
|
||||
workflow = Workflow(
|
||||
tenant_id=harness.target.tenant_id,
|
||||
app_id=harness.target.id,
|
||||
type=WorkflowType.CHAT,
|
||||
version="published",
|
||||
graph='{"nodes":[],"edges":[]}',
|
||||
features=features,
|
||||
created_by=harness.account.id,
|
||||
)
|
||||
session.add(workflow)
|
||||
session.flush()
|
||||
session.execute(update(App).where(App.id == harness.target.id).values({App.workflow_id: workflow.id}))
|
||||
|
||||
dirty_workflows: list[str] = []
|
||||
|
||||
def record_changes(workflow: Workflow, _value: object, _old_value: object, _initiator: object) -> None:
|
||||
dirty_workflows.append(workflow.id)
|
||||
|
||||
# Even a rolled-back assignment would leave a supposedly read-only query dirty.
|
||||
event.listen(Workflow._features, "set", record_changes)
|
||||
try:
|
||||
context = harness.prepare(actor, invoke_from="explore")
|
||||
finally:
|
||||
event.remove(Workflow._features, "set", record_changes)
|
||||
assert not dirty_workflows
|
||||
with harness.factory() as session:
|
||||
persisted = session.get(Workflow, workflow.id)
|
||||
assert persisted is not None
|
||||
assert persisted.serialized_features == features
|
||||
assert context.config == {"enabled": True, "prompt": "Legacy workflow questions"}
|
||||
harness.assert_closed()
|
||||
@@ -620,6 +620,7 @@ export const get24Errors = { app_not_found: { status: 404, data: zAppNotFoundErr
|
||||
export const get25Errors = { app_not_found: { status: 404, data: zAppNotFoundError } }
|
||||
|
||||
export const get26Errors = {
|
||||
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
|
||||
app_not_found: { status: 404, data: zAppNotFoundError },
|
||||
app_suggested_questions_after_answer_disabled: {
|
||||
status: 403,
|
||||
|
||||
@@ -1764,6 +1764,7 @@ export const get26 = oc
|
||||
.input(z.object({ params: zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsPath }))
|
||||
.output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse)
|
||||
.errors({
|
||||
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
|
||||
app_not_found: { status: 404, data: zAppNotFoundError },
|
||||
app_suggested_questions_after_answer_disabled: {
|
||||
status: 403,
|
||||
|
||||
+1
-1
@@ -6169,7 +6169,7 @@ export type GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsErrors = {
|
||||
| ErrorResponse
|
||||
401: ErrorResponse
|
||||
403: AppSuggestedQuestionsAfterAnswerDisabledError | ErrorResponse
|
||||
404: AppNotFoundError | ErrorResponse
|
||||
404: AgentVersionNotFoundError | AppNotFoundError | ErrorResponse
|
||||
500: ErrorResponse
|
||||
default: ErrorResponse
|
||||
}
|
||||
|
||||
@@ -436,6 +436,7 @@ export const post5Errors = {
|
||||
}
|
||||
|
||||
export const get13Errors = {
|
||||
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
|
||||
app_suggested_questions_after_answer_disabled: {
|
||||
status: 403,
|
||||
data: zAppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
|
||||
@@ -596,6 +596,7 @@ export const get13 = oc
|
||||
.input(z.object({ params: zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsPath }))
|
||||
.output(zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponse)
|
||||
.errors({
|
||||
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
|
||||
app_suggested_questions_after_answer_disabled: {
|
||||
status: 403,
|
||||
data: zAppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
|
||||
@@ -2059,6 +2059,7 @@ export type GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsErrors = {
|
||||
| TrialAppLimitExceededError
|
||||
| TrialAppNotAllowedError
|
||||
| ErrorResponse
|
||||
404: AgentVersionNotFoundError | ErrorResponse
|
||||
default: ErrorResponse
|
||||
}
|
||||
|
||||
|
||||
+74
-1
@@ -18,6 +18,46 @@ const zAgentNotPublishedError = /* @__PURE__ */ (() =>
|
||||
}),
|
||||
))()
|
||||
|
||||
const zAgentVersionNotFoundError = /* @__PURE__ */ (() =>
|
||||
zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('agent_version_not_found_error'),
|
||||
status: z.literal(404),
|
||||
}),
|
||||
))()
|
||||
|
||||
const zAppAbnormalStatusError = /* @__PURE__ */ (() =>
|
||||
zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('app_abnormal_status'),
|
||||
status: z.literal(403),
|
||||
}),
|
||||
))()
|
||||
|
||||
const zAppApiDisabledError = /* @__PURE__ */ (() =>
|
||||
zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('app_api_disabled'),
|
||||
status: z.literal(403),
|
||||
}),
|
||||
))()
|
||||
|
||||
const zAppNotFoundError = /* @__PURE__ */ (() =>
|
||||
zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('app_not_found'),
|
||||
status: z.literal(403),
|
||||
}),
|
||||
))()
|
||||
|
||||
const zAppSuggestedQuestionsAfterAnswerDisabledError = /* @__PURE__ */ (() =>
|
||||
zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('app_suggested_questions_after_answer_disabled'),
|
||||
status: z.literal(403),
|
||||
}),
|
||||
))()
|
||||
|
||||
const zAppUnavailableError = /* @__PURE__ */ (() =>
|
||||
zErrorResponse.and(
|
||||
z.object({
|
||||
@@ -354,6 +394,22 @@ const zWorkflowVersionExecutionNotAllowedError = /* @__PURE__ */ (() =>
|
||||
}),
|
||||
))()
|
||||
|
||||
const zWorkspaceArchivedError = /* @__PURE__ */ (() =>
|
||||
zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('workspace_archived'),
|
||||
status: z.literal(403),
|
||||
}),
|
||||
))()
|
||||
|
||||
const zWorkspaceNotFoundError = /* @__PURE__ */ (() =>
|
||||
zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('workspace_not_found'),
|
||||
status: z.literal(403),
|
||||
}),
|
||||
))()
|
||||
|
||||
export const post3Errors = {
|
||||
app_unavailable: { status: 400, data: zAppUnavailableError },
|
||||
audio_too_large: { status: 413, data: zAudioTooLargeError },
|
||||
@@ -1185,7 +1241,24 @@ export const get60Errors = {
|
||||
knowledge_fs_upstream_unavailable: { status: 502, data: zKnowledgeFsUpstreamUnavailableError },
|
||||
}
|
||||
|
||||
export const get61Errors = { not_chat_app: { status: 400, data: zNotChatAppError } }
|
||||
export const get61Errors = {
|
||||
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
|
||||
app_abnormal_status: { status: 403, data: zAppAbnormalStatusError },
|
||||
app_api_disabled: { status: 403, data: zAppApiDisabledError },
|
||||
app_not_found: { status: 403, data: zAppNotFoundError },
|
||||
app_suggested_questions_after_answer_disabled: {
|
||||
status: 403,
|
||||
data: zAppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
},
|
||||
app_unavailable: { status: 400, data: zAppUnavailableError },
|
||||
completion_request_error: { status: 400, data: zCompletionRequestError },
|
||||
model_currently_not_support: { status: 400, data: zModelCurrentlyNotSupportError },
|
||||
not_chat_app: { status: 400, data: zNotChatAppError },
|
||||
provider_not_initialize: { status: 400, data: zProviderNotInitializeError },
|
||||
provider_quota_exceeded: { status: 400, data: zProviderQuotaExceededError },
|
||||
workspace_archived: { status: 403, data: zWorkspaceArchivedError },
|
||||
workspace_not_found: { status: 403, data: zWorkspaceNotFoundError },
|
||||
}
|
||||
|
||||
export const get62Errors = { not_chat_app: { status: 400, data: zNotChatAppError } }
|
||||
|
||||
|
||||
+28
-3
@@ -4,6 +4,11 @@ import { oc } from '@orpc/contract'
|
||||
import * as z from 'zod'
|
||||
import {
|
||||
zAgentNotPublishedError,
|
||||
zAgentVersionNotFoundError,
|
||||
zAppAbnormalStatusError,
|
||||
zAppApiDisabledError,
|
||||
zAppNotFoundError,
|
||||
zAppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
zAppUnavailableError,
|
||||
zArchivedDocumentImmutableError,
|
||||
zAudioTooLargeError,
|
||||
@@ -46,6 +51,8 @@ import {
|
||||
zUnsupportedAudioTypeError,
|
||||
zUnsupportedFileTypeError,
|
||||
zWorkflowVersionExecutionNotAllowedError,
|
||||
zWorkspaceArchivedError,
|
||||
zWorkspaceNotFoundError,
|
||||
} from './zod.gen.ts'
|
||||
import {
|
||||
zDeleteAppsAnnotationsByAnnotationIdPath,
|
||||
@@ -4729,11 +4736,12 @@ export const feedbacks2 = {
|
||||
/**
|
||||
* Get Next Suggested Questions
|
||||
*
|
||||
* Get next questions suggestions for the current message.
|
||||
* 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`.
|
||||
*/
|
||||
export const get61 = oc
|
||||
.route({
|
||||
description: 'Get next questions suggestions for the current message.',
|
||||
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`.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getMessagesByMessageIdSuggested',
|
||||
@@ -4748,7 +4756,24 @@ export const get61 = oc
|
||||
}),
|
||||
)
|
||||
.output(zGetMessagesByMessageIdSuggestedResponse)
|
||||
.errors({ not_chat_app: { status: 400, data: zNotChatAppError } })
|
||||
.errors({
|
||||
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
|
||||
app_abnormal_status: { status: 403, data: zAppAbnormalStatusError },
|
||||
app_api_disabled: { status: 403, data: zAppApiDisabledError },
|
||||
app_not_found: { status: 403, data: zAppNotFoundError },
|
||||
app_suggested_questions_after_answer_disabled: {
|
||||
status: 403,
|
||||
data: zAppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
},
|
||||
app_unavailable: { status: 400, data: zAppUnavailableError },
|
||||
completion_request_error: { status: 400, data: zCompletionRequestError },
|
||||
model_currently_not_support: { status: 400, data: zModelCurrentlyNotSupportError },
|
||||
not_chat_app: { status: 400, data: zNotChatAppError },
|
||||
provider_not_initialize: { status: 400, data: zProviderNotInitializeError },
|
||||
provider_quota_exceeded: { status: 400, data: zProviderQuotaExceededError },
|
||||
workspace_archived: { status: 403, data: zWorkspaceArchivedError },
|
||||
workspace_not_found: { status: 403, data: zWorkspaceNotFoundError },
|
||||
})
|
||||
|
||||
export const suggested = {
|
||||
get: get61,
|
||||
|
||||
+52
-3
@@ -24,6 +24,11 @@ export type AgentThought = {
|
||||
tool_labels: AnyJsonValue
|
||||
}
|
||||
|
||||
export type AgentVersionNotFoundError = ErrorResponse & {
|
||||
code: 'agent_version_not_found_error'
|
||||
status: 404
|
||||
}
|
||||
|
||||
export type Annotation = {
|
||||
answer?: string | null
|
||||
created_at?: number | null
|
||||
@@ -70,6 +75,16 @@ export type AnnotationReplyActionPayload = {
|
||||
|
||||
export type AnyJsonValue = unknown
|
||||
|
||||
export type AppAbnormalStatusError = ErrorResponse & {
|
||||
code: 'app_abnormal_status'
|
||||
status: 403
|
||||
}
|
||||
|
||||
export type AppApiDisabledError = ErrorResponse & {
|
||||
code: 'app_api_disabled'
|
||||
status: 403
|
||||
}
|
||||
|
||||
export type AppFeedbackListResponse = {
|
||||
data: Array<AppFeedbackResponse>
|
||||
}
|
||||
@@ -102,6 +117,16 @@ export type AppMetaResponse = {
|
||||
}
|
||||
}
|
||||
|
||||
export type AppNotFoundError = ErrorResponse & {
|
||||
code: 'app_not_found'
|
||||
status: 403
|
||||
}
|
||||
|
||||
export type AppSuggestedQuestionsAfterAnswerDisabledError = ErrorResponse & {
|
||||
code: 'app_suggested_questions_after_answer_disabled'
|
||||
status: 403
|
||||
}
|
||||
|
||||
export type AppUnavailableError = ErrorResponse & {
|
||||
code: 'app_unavailable'
|
||||
status: 400
|
||||
@@ -4450,6 +4475,16 @@ export type WorkflowVersionExecutionNotAllowedError = ErrorResponse & {
|
||||
status: 403
|
||||
}
|
||||
|
||||
export type WorkspaceArchivedError = ErrorResponse & {
|
||||
code: 'workspace_archived'
|
||||
status: 403
|
||||
}
|
||||
|
||||
export type WorkspaceNotFoundError = ErrorResponse & {
|
||||
code: 'workspace_not_found'
|
||||
status: 403
|
||||
}
|
||||
|
||||
export type HumanInputFormSubmitResponseWritable = {
|
||||
[key: string]: never
|
||||
}
|
||||
@@ -8821,10 +8856,24 @@ export type GetMessagesByMessageIdSuggestedData = {
|
||||
}
|
||||
|
||||
export type GetMessagesByMessageIdSuggestedErrors = {
|
||||
400: NotChatAppError | ErrorResponse
|
||||
400:
|
||||
| AppUnavailableError
|
||||
| CompletionRequestError
|
||||
| ModelCurrentlyNotSupportError
|
||||
| NotChatAppError
|
||||
| ProviderNotInitializeError
|
||||
| ProviderQuotaExceededError
|
||||
| ErrorResponse
|
||||
401: ErrorResponse
|
||||
403: ErrorResponse
|
||||
404: ErrorResponse
|
||||
403:
|
||||
| AppAbnormalStatusError
|
||||
| AppApiDisabledError
|
||||
| AppNotFoundError
|
||||
| AppSuggestedQuestionsAfterAnswerDisabledError
|
||||
| WorkspaceArchivedError
|
||||
| WorkspaceNotFoundError
|
||||
| ErrorResponse
|
||||
404: AgentVersionNotFoundError | ErrorResponse
|
||||
500: ErrorResponse
|
||||
default: ErrorResponse
|
||||
}
|
||||
|
||||
+84
@@ -944,6 +944,66 @@ export const zAgentNotPublishedError = zErrorResponse.and(
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* AgentVersionNotFoundError
|
||||
*
|
||||
* Agent config version not found.
|
||||
*/
|
||||
export const zAgentVersionNotFoundError = zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('agent_version_not_found_error'),
|
||||
status: z.literal(404),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* AppAbnormalStatusError
|
||||
*
|
||||
* The app's status is abnormal.
|
||||
*/
|
||||
export const zAppAbnormalStatusError = zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('app_abnormal_status'),
|
||||
status: z.literal(403),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* AppApiDisabledError
|
||||
*
|
||||
* The app's API service has been disabled.
|
||||
*/
|
||||
export const zAppApiDisabledError = zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('app_api_disabled'),
|
||||
status: z.literal(403),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* AppNotFoundError
|
||||
*
|
||||
* The app no longer exists.
|
||||
*/
|
||||
export const zAppNotFoundError = zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('app_not_found'),
|
||||
status: z.literal(403),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* AppSuggestedQuestionsAfterAnswerDisabledError
|
||||
*
|
||||
* The 'Suggested Questions After Answer' feature is disabled.
|
||||
*/
|
||||
export const zAppSuggestedQuestionsAfterAnswerDisabledError = zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('app_suggested_questions_after_answer_disabled'),
|
||||
status: z.literal(403),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* AppUnavailableError
|
||||
*
|
||||
@@ -5866,6 +5926,30 @@ export const zWorkflowVersionExecutionNotAllowedError = zErrorResponse.and(
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* WorkspaceArchivedError
|
||||
*
|
||||
* The workspace's status is archived.
|
||||
*/
|
||||
export const zWorkspaceArchivedError = zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('workspace_archived'),
|
||||
status: z.literal(403),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* WorkspaceNotFoundError
|
||||
*
|
||||
* Tenant does not exist.
|
||||
*/
|
||||
export const zWorkspaceNotFoundError = zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('workspace_not_found'),
|
||||
status: z.literal(403),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* HumanInputFormSubmitResponse
|
||||
*/
|
||||
|
||||
+10
@@ -26,6 +26,14 @@ const zAgentNotPublishedError = /* @__PURE__ */ (() =>
|
||||
}),
|
||||
))()
|
||||
|
||||
const zAgentVersionNotFoundError = /* @__PURE__ */ (() =>
|
||||
zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('agent_version_not_found_error'),
|
||||
status: z.literal(404),
|
||||
}),
|
||||
))()
|
||||
|
||||
const zAppMoreLikeThisDisabledError = /* @__PURE__ */ (() =>
|
||||
zErrorResponse.and(
|
||||
z.object({
|
||||
@@ -526,10 +534,12 @@ export const get4Errors = {
|
||||
}
|
||||
|
||||
export const get5Errors = {
|
||||
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
|
||||
app_suggested_questions_after_answer_disabled: {
|
||||
status: 403,
|
||||
data: zAppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
},
|
||||
app_unavailable: { status: 400, data: zAppUnavailableError },
|
||||
completion_request_error: { status: 400, data: zCompletionRequestError },
|
||||
model_currently_not_support: { status: 400, data: zModelCurrentlyNotSupportError },
|
||||
not_chat_app: { status: 400, data: zNotChatAppError },
|
||||
|
||||
+6
-2
@@ -5,6 +5,7 @@ import * as z from 'zod'
|
||||
import {
|
||||
zAccountBannedError,
|
||||
zAgentNotPublishedError,
|
||||
zAgentVersionNotFoundError,
|
||||
zAppMoreLikeThisDisabledError,
|
||||
zAppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
zAppUnavailableError,
|
||||
@@ -841,11 +842,12 @@ export const moreLikeThis = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested follow-up questions after a message (chat apps only).
|
||||
* Get suggested follow-up questions after a message (chat apps only). 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`.
|
||||
*/
|
||||
export const get5 = oc
|
||||
.route({
|
||||
description: 'Get suggested follow-up questions after a message (chat apps only).',
|
||||
description:
|
||||
'Get suggested follow-up questions after a message (chat apps only). 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`.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getMessagesByMessageIdSuggestedQuestions',
|
||||
@@ -855,10 +857,12 @@ export const get5 = oc
|
||||
.input(z.object({ params: zGetMessagesByMessageIdSuggestedQuestionsPath }))
|
||||
.output(zGetMessagesByMessageIdSuggestedQuestionsResponse)
|
||||
.errors({
|
||||
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
|
||||
app_suggested_questions_after_answer_disabled: {
|
||||
status: 403,
|
||||
data: zAppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
},
|
||||
app_unavailable: { status: 400, data: zAppUnavailableError },
|
||||
completion_request_error: { status: 400, data: zCompletionRequestError },
|
||||
model_currently_not_support: { status: 400, data: zModelCurrentlyNotSupportError },
|
||||
not_chat_app: { status: 400, data: zNotChatAppError },
|
||||
|
||||
+7
-1
@@ -42,6 +42,11 @@ export type AgentThought = {
|
||||
tool_labels: AnyJsonValue
|
||||
}
|
||||
|
||||
export type AgentVersionNotFoundError = ErrorResponse & {
|
||||
code: 'agent_version_not_found_error'
|
||||
status: 404
|
||||
}
|
||||
|
||||
export type AnyJsonValue = unknown
|
||||
|
||||
export type AppAccessModeQuery = {
|
||||
@@ -2032,6 +2037,7 @@ export type GetMessagesByMessageIdSuggestedQuestionsData = {
|
||||
|
||||
export type GetMessagesByMessageIdSuggestedQuestionsErrors = {
|
||||
400:
|
||||
| AppUnavailableError
|
||||
| CompletionRequestError
|
||||
| ModelCurrentlyNotSupportError
|
||||
| NotChatAppError
|
||||
@@ -2040,7 +2046,7 @@ export type GetMessagesByMessageIdSuggestedQuestionsErrors = {
|
||||
| ErrorResponse
|
||||
401: ErrorResponse
|
||||
403: AppSuggestedQuestionsAfterAnswerDisabledError | ErrorResponse
|
||||
404: ErrorResponse
|
||||
404: AgentVersionNotFoundError | ErrorResponse
|
||||
500: ErrorResponse
|
||||
default: ErrorResponse
|
||||
}
|
||||
|
||||
+12
@@ -230,6 +230,18 @@ export const zAgentNotPublishedError = zErrorResponse.and(
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* AgentVersionNotFoundError
|
||||
*
|
||||
* Agent config version not found.
|
||||
*/
|
||||
export const zAgentVersionNotFoundError = zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('agent_version_not_found_error'),
|
||||
status: z.literal(404),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* AppMoreLikeThisDisabledError
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user