mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
refactor(api): share console conversation queries and deletion (#44043)
This commit is contained in:
32 files changed
+4149
-1019
No files matched your search
@@ -1061,6 +1061,19 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:console-conversation-controller-boundary]
|
||||
name = Console conversation controllers reach persistence through the composition root
|
||||
type = forbidden
|
||||
source_modules =
|
||||
controllers.console.app.conversation
|
||||
forbidden_modules =
|
||||
extensions.ext_database
|
||||
models
|
||||
repositories
|
||||
services.conversation_service
|
||||
sqlalchemy
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:billing-application-boundary]
|
||||
name = Billing application services and ports are framework and infrastructure neutral
|
||||
type = forbidden
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import abort
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.errors import ConversationNotFoundHTTPError, InvalidArgumentError, UnauthorizedError
|
||||
from controllers.common.rbac import PlainApp, RBACCheck
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import RBACPermission, validate_request
|
||||
from enums.account import TenantAccountRole
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.conversation_fields import (
|
||||
Conversation as ConversationResponse,
|
||||
)
|
||||
@@ -36,18 +26,16 @@ from fields.conversation_fields import (
|
||||
from fields.conversation_fields import (
|
||||
ConversationPagination as ConversationPaginationResponse,
|
||||
)
|
||||
from fields.conversation_fields import ConversationResponseSource
|
||||
from fields.conversation_fields import (
|
||||
ConversationWithSummaryPagination as ConversationWithSummaryPaginationResponse,
|
||||
)
|
||||
from libs.datetime_utils import naive_utc_now, parse_time_range
|
||||
from libs.datetime_utils import parse_time_range
|
||||
from libs.external_api import error_responses
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from libs.pagination import paginate_query
|
||||
from models import Conversation, EndUser, Message, MessageAnnotation
|
||||
from models.account import Account
|
||||
from models.model import App, AppMode
|
||||
from services.conversation_service import ConversationService
|
||||
from machinery.context import RequestContext
|
||||
from services.app.console_service import ConsoleAppNotFoundError
|
||||
from services.entities.conversation_entities import ConsoleConversationFilters
|
||||
from services.errors.base import NoPermissionError
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
|
||||
|
||||
@@ -96,322 +84,205 @@ register_response_schema_models(
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/completion-conversations")
|
||||
class CompletionConversationApi(Resource):
|
||||
@error_responses(AppNotFoundError)
|
||||
@console_ns.doc("list_completion_conversations")
|
||||
@console_ns.doc(description="Get completion conversations with pagination and filtering")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(CompletionConversationQuery)})
|
||||
@console_ns.response(200, "Success", console_ns.models[ConversationPaginationResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=AppMode.COMPLETION)
|
||||
@model_validate(CompletionConversationQuery)
|
||||
def get(self, req_data: CompletionConversationQuery, session: Session, current_user: Account, app_model: App):
|
||||
query = sa.select(Conversation).where(
|
||||
Conversation.app_id == app_model.id, Conversation.mode == "completion", Conversation.is_deleted.is_(False)
|
||||
)
|
||||
|
||||
if req_data.keyword:
|
||||
from libs.helper import escape_like_pattern
|
||||
|
||||
escaped_keyword = escape_like_pattern(req_data.keyword)
|
||||
query = query.join(Message, Message.conversation_id == Conversation.id).where(
|
||||
or_(
|
||||
Message.query.ilike(f"%{escaped_keyword}%", escape="\\"),
|
||||
Message.answer.ilike(f"%{escaped_keyword}%", escape="\\"),
|
||||
)
|
||||
)
|
||||
|
||||
account = current_user
|
||||
assert account.timezone is not None
|
||||
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ConversationPaginationResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.BAD_REQUEST, "Invalid date range")
|
||||
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Authentication required")
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions")
|
||||
@console_ns.response(HTTPStatus.NOT_FOUND, "App not found")
|
||||
@console_ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, "Invalid query parameters")
|
||||
@console_account_admission(
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}),
|
||||
rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),),
|
||||
)
|
||||
def get(self, request_context: RequestContext, app_id: UUID) -> dict[str, object]:
|
||||
queries = application_services().conversation_queries
|
||||
try:
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(req_data.start, req_data.end, account.timezone)
|
||||
except ValueError as e:
|
||||
abort(400, description=str(e))
|
||||
|
||||
if start_datetime_utc:
|
||||
query = query.where(Conversation.created_at >= start_datetime_utc)
|
||||
|
||||
if end_datetime_utc:
|
||||
end_datetime_utc = end_datetime_utc.replace(second=59)
|
||||
query = query.where(Conversation.created_at <= end_datetime_utc)
|
||||
|
||||
# FIXME, the type ignore in this file
|
||||
if req_data.annotation_status == "annotated":
|
||||
query = (
|
||||
query.options(selectinload(Conversation.message_annotations)) # type: ignore[arg-type]
|
||||
.join( # type: ignore
|
||||
MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
|
||||
)
|
||||
.group_by(Conversation.id)
|
||||
scope = queries.get_console_scope(request_context, str(app_id), kind="completion")
|
||||
except ConsoleAppNotFoundError as exc:
|
||||
raise AppNotFoundError(str(exc)) from exc
|
||||
except NoPermissionError as exc:
|
||||
raise UnauthorizedError(str(exc)) from exc
|
||||
req_data = validate_request(CompletionConversationQuery)
|
||||
try:
|
||||
start, end = parse_time_range(req_data.start, req_data.end, scope.timezone)
|
||||
except ValueError as exc:
|
||||
raise InvalidArgumentError(str(exc)) from exc
|
||||
try:
|
||||
page = queries.get_console_page(
|
||||
scope=scope,
|
||||
filters=ConsoleConversationFilters(
|
||||
keyword=req_data.keyword,
|
||||
start=start,
|
||||
end=end.replace(second=59) if end else None,
|
||||
annotation_status=req_data.annotation_status,
|
||||
sort_by="-created_at",
|
||||
page=req_data.page,
|
||||
limit=req_data.limit,
|
||||
),
|
||||
)
|
||||
elif req_data.annotation_status == "not_annotated":
|
||||
query = (
|
||||
query.outerjoin(MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id)
|
||||
.group_by(Conversation.id)
|
||||
.having(func.count(MessageAnnotation.id) == 0)
|
||||
)
|
||||
|
||||
query = query.order_by(Conversation.created_at.desc())
|
||||
|
||||
conversations = paginate_query(query, session=session, page=req_data.page, per_page=req_data.limit)
|
||||
|
||||
return dump_response(
|
||||
ConversationPaginationResponse,
|
||||
{
|
||||
"page": conversations.page,
|
||||
"per_page": conversations.per_page,
|
||||
"total": conversations.total,
|
||||
"has_next": conversations.has_next,
|
||||
"items": [ConversationResponseSource(item, session=session) for item in conversations.items],
|
||||
},
|
||||
)
|
||||
except ConsoleAppNotFoundError as exc:
|
||||
raise AppNotFoundError(str(exc)) from exc
|
||||
return dump_response(ConversationPaginationResponse, page)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/completion-conversations/<uuid:conversation_id>")
|
||||
class CompletionConversationDetailApi(Resource):
|
||||
@error_responses(AppNotFoundError, ConversationNotFoundHTTPError)
|
||||
@console_ns.doc("get_completion_conversation")
|
||||
@console_ns.doc(description="Get completion conversation details with messages")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
|
||||
@console_ns.response(200, "Success", console_ns.models[ConversationMessageDetailResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(404, "Conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
|
||||
@with_session
|
||||
@get_app_model(mode=AppMode.COMPLETION)
|
||||
def get(self, session: Session, current_user: Account, app_model: App, conversation_id: UUID):
|
||||
conversation_id_str = str(conversation_id)
|
||||
return dump_response(
|
||||
ConversationMessageDetailResponse,
|
||||
ConversationResponseSource(
|
||||
_get_conversation(session, current_user, app_model, conversation_id_str), session=session
|
||||
),
|
||||
)
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ConversationMessageDetailResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Authentication required")
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions")
|
||||
@console_ns.response(HTTPStatus.NOT_FOUND, "App or conversation not found")
|
||||
@console_account_admission(
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}),
|
||||
rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),),
|
||||
)
|
||||
def get(self, request_context: RequestContext, app_id: UUID, conversation_id: UUID) -> dict[str, object]:
|
||||
services = application_services()
|
||||
try:
|
||||
detail = services.conversation_queries.get_console_detail(
|
||||
request_context, str(app_id), str(conversation_id), kind="completion"
|
||||
)
|
||||
response = dump_response(ConversationMessageDetailResponse, detail)
|
||||
# Preserve the Console read receipt only after rendering succeeds.
|
||||
services.conversation_commands.mark_console_read(
|
||||
request_context, str(app_id), str(conversation_id), kind="completion"
|
||||
)
|
||||
except ConsoleAppNotFoundError as exc:
|
||||
raise AppNotFoundError(str(exc)) from exc
|
||||
except ConversationNotExistsError as exc:
|
||||
raise ConversationNotFoundHTTPError() from exc
|
||||
return response
|
||||
|
||||
@error_responses(AppNotFoundError, ConversationNotFoundHTTPError)
|
||||
@console_ns.doc("delete_completion_conversation")
|
||||
@console_ns.doc(description="Delete a completion conversation")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
|
||||
@console_ns.response(204, "Conversation deleted successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(404, "Conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=AppMode.COMPLETION)
|
||||
def delete(self, session: Session, current_user: Account, app_model: App, conversation_id: UUID):
|
||||
conversation_id_str = str(conversation_id)
|
||||
|
||||
@console_ns.response(HTTPStatus.NO_CONTENT, "Conversation deleted successfully")
|
||||
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Authentication required")
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions")
|
||||
@console_ns.response(HTTPStatus.NOT_FOUND, "App or conversation not found")
|
||||
@console_account_admission(
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}),
|
||||
rbac_checks=(RBACCheck(RBACPermission.APP_EDIT, PlainApp()),),
|
||||
)
|
||||
def delete(self, request_context: RequestContext, app_id: UUID, conversation_id: UUID) -> tuple[str, HTTPStatus]:
|
||||
try:
|
||||
ConversationService.delete(app_model, conversation_id_str, current_user, session=session)
|
||||
except ConversationNotExistsError as caught_error:
|
||||
raise NotFound("Conversation Not Exists.") from caught_error
|
||||
|
||||
return "", 204
|
||||
application_services().conversation_commands.delete_console(
|
||||
request_context, str(app_id), str(conversation_id), kind="completion"
|
||||
)
|
||||
except ConsoleAppNotFoundError as exc:
|
||||
raise AppNotFoundError(str(exc)) from exc
|
||||
except ConversationNotExistsError as exc:
|
||||
raise ConversationNotFoundHTTPError() from exc
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/chat-conversations")
|
||||
class ChatConversationApi(Resource):
|
||||
@error_responses(AppNotFoundError)
|
||||
@console_ns.doc("list_chat_conversations")
|
||||
@console_ns.doc(description="Get chat conversations with pagination, filtering and summary")
|
||||
@console_ns.doc(description="Get chat conversations with pagination and filtering")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(ChatConversationQuery)})
|
||||
@console_ns.response(200, "Success", console_ns.models[ConversationWithSummaryPaginationResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@model_validate(ChatConversationQuery)
|
||||
def get(self, req_data: ChatConversationQuery, session: Session, current_user: Account, app_model: App):
|
||||
subquery = (
|
||||
sa.select(Conversation.id.label("conversation_id"), EndUser.session_id.label("from_end_user_session_id"))
|
||||
.outerjoin(EndUser, Conversation.from_end_user_id == EndUser.id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
query = sa.select(Conversation).where(Conversation.app_id == app_model.id, Conversation.is_deleted.is_(False))
|
||||
|
||||
if req_data.keyword:
|
||||
from libs.helper import escape_like_pattern
|
||||
|
||||
escaped_keyword = escape_like_pattern(req_data.keyword)
|
||||
keyword_filter = f"%{escaped_keyword}%"
|
||||
query = (
|
||||
query.join(
|
||||
Message,
|
||||
Message.conversation_id == Conversation.id,
|
||||
)
|
||||
.join(subquery, subquery.c.conversation_id == Conversation.id)
|
||||
.where(
|
||||
or_(
|
||||
Message.query.ilike(keyword_filter, escape="\\"),
|
||||
Message.answer.ilike(keyword_filter, escape="\\"),
|
||||
Conversation.name.ilike(keyword_filter, escape="\\"),
|
||||
Conversation.introduction.ilike(keyword_filter, escape="\\"),
|
||||
subquery.c.from_end_user_session_id.ilike(keyword_filter, escape="\\"),
|
||||
),
|
||||
)
|
||||
.group_by(Conversation.id)
|
||||
)
|
||||
|
||||
account = current_user
|
||||
assert account.timezone is not None
|
||||
|
||||
@console_ns.response(
|
||||
HTTPStatus.OK, "Success", console_ns.models[ConversationWithSummaryPaginationResponse.__name__]
|
||||
)
|
||||
@console_ns.response(HTTPStatus.BAD_REQUEST, "Invalid date range")
|
||||
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Authentication required")
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions")
|
||||
@console_ns.response(HTTPStatus.NOT_FOUND, "App not found")
|
||||
@console_ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, "Invalid query parameters")
|
||||
@console_account_admission(
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}),
|
||||
rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),),
|
||||
)
|
||||
def get(self, request_context: RequestContext, app_id: UUID) -> dict[str, object]:
|
||||
queries = application_services().conversation_queries
|
||||
try:
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(req_data.start, req_data.end, account.timezone)
|
||||
except ValueError as e:
|
||||
abort(400, description=str(e))
|
||||
|
||||
if start_datetime_utc:
|
||||
match req_data.sort_by:
|
||||
case "updated_at" | "-updated_at":
|
||||
query = query.where(Conversation.updated_at >= start_datetime_utc)
|
||||
case "created_at" | "-created_at" | _:
|
||||
query = query.where(Conversation.created_at >= start_datetime_utc)
|
||||
|
||||
if end_datetime_utc:
|
||||
end_datetime_utc = end_datetime_utc.replace(second=59)
|
||||
match req_data.sort_by:
|
||||
case "updated_at" | "-updated_at":
|
||||
query = query.where(Conversation.updated_at <= end_datetime_utc)
|
||||
case "created_at" | "-created_at" | _:
|
||||
query = query.where(Conversation.created_at <= end_datetime_utc)
|
||||
|
||||
match req_data.annotation_status:
|
||||
case "annotated":
|
||||
query = (
|
||||
query.options(selectinload(Conversation.message_annotations)) # type: ignore[arg-type]
|
||||
.join( # type: ignore
|
||||
MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
|
||||
)
|
||||
.group_by(Conversation.id)
|
||||
)
|
||||
case "not_annotated":
|
||||
query = (
|
||||
query.outerjoin(MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id)
|
||||
.group_by(Conversation.id)
|
||||
.having(func.count(MessageAnnotation.id) == 0)
|
||||
)
|
||||
case "all":
|
||||
pass
|
||||
|
||||
if app_model.mode == AppMode.ADVANCED_CHAT:
|
||||
query = query.where(Conversation.invoke_from != InvokeFrom.DEBUGGER)
|
||||
|
||||
match req_data.sort_by:
|
||||
case "created_at":
|
||||
query = query.order_by(Conversation.created_at.asc())
|
||||
case "-created_at":
|
||||
query = query.order_by(Conversation.created_at.desc())
|
||||
case "updated_at":
|
||||
query = query.order_by(Conversation.updated_at.asc())
|
||||
case "-updated_at":
|
||||
query = query.order_by(Conversation.updated_at.desc())
|
||||
case _:
|
||||
query = query.order_by(Conversation.created_at.desc())
|
||||
|
||||
conversations = paginate_query(query, session=session, page=req_data.page, per_page=req_data.limit)
|
||||
|
||||
return dump_response(
|
||||
ConversationWithSummaryPaginationResponse,
|
||||
{
|
||||
"page": conversations.page,
|
||||
"per_page": conversations.per_page,
|
||||
"total": conversations.total,
|
||||
"has_next": conversations.has_next,
|
||||
"items": [ConversationResponseSource(item, session=session) for item in conversations.items],
|
||||
},
|
||||
)
|
||||
scope = queries.get_console_scope(request_context, str(app_id), kind="chat")
|
||||
except ConsoleAppNotFoundError as exc:
|
||||
raise AppNotFoundError(str(exc)) from exc
|
||||
except NoPermissionError as exc:
|
||||
raise UnauthorizedError(str(exc)) from exc
|
||||
req_data = validate_request(ChatConversationQuery)
|
||||
try:
|
||||
start, end = parse_time_range(req_data.start, req_data.end, scope.timezone)
|
||||
except ValueError as exc:
|
||||
raise InvalidArgumentError(str(exc)) from exc
|
||||
try:
|
||||
page = queries.get_console_page(
|
||||
scope=scope,
|
||||
filters=ConsoleConversationFilters(
|
||||
keyword=req_data.keyword,
|
||||
start=start,
|
||||
end=end.replace(second=59) if end else None,
|
||||
annotation_status=req_data.annotation_status,
|
||||
sort_by=req_data.sort_by,
|
||||
page=req_data.page,
|
||||
limit=req_data.limit,
|
||||
),
|
||||
)
|
||||
except ConsoleAppNotFoundError as exc:
|
||||
raise AppNotFoundError(str(exc)) from exc
|
||||
return dump_response(ConversationWithSummaryPaginationResponse, page)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/chat-conversations/<uuid:conversation_id>")
|
||||
class ChatConversationDetailApi(Resource):
|
||||
@error_responses(AppNotFoundError, ConversationNotFoundHTTPError)
|
||||
@console_ns.doc("get_chat_conversation")
|
||||
@console_ns.doc(description="Get chat conversation details")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
|
||||
@console_ns.response(200, "Success", console_ns.models[ConversationDetailResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(404, "Conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
|
||||
@with_session
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
def get(self, session: Session, current_user: Account, app_model: App, conversation_id: UUID):
|
||||
conversation_id_str = str(conversation_id)
|
||||
return dump_response(
|
||||
ConversationDetailResponse,
|
||||
ConversationResponseSource(
|
||||
_get_conversation(session, current_user, app_model, conversation_id_str), session=session
|
||||
),
|
||||
)
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ConversationDetailResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Authentication required")
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions")
|
||||
@console_ns.response(HTTPStatus.NOT_FOUND, "App or conversation not found")
|
||||
@console_account_admission(
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}),
|
||||
rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),),
|
||||
)
|
||||
def get(self, request_context: RequestContext, app_id: UUID, conversation_id: UUID) -> dict[str, object]:
|
||||
services = application_services()
|
||||
try:
|
||||
detail = services.conversation_queries.get_console_detail(
|
||||
request_context, str(app_id), str(conversation_id), kind="chat"
|
||||
)
|
||||
response = dump_response(ConversationDetailResponse, detail)
|
||||
# Preserve the Console read receipt only after rendering succeeds.
|
||||
services.conversation_commands.mark_console_read(
|
||||
request_context, str(app_id), str(conversation_id), kind="chat"
|
||||
)
|
||||
except ConsoleAppNotFoundError as exc:
|
||||
raise AppNotFoundError(str(exc)) from exc
|
||||
except ConversationNotExistsError as exc:
|
||||
raise ConversationNotFoundHTTPError() from exc
|
||||
return response
|
||||
|
||||
@error_responses(AppNotFoundError, ConversationNotFoundHTTPError)
|
||||
@console_ns.doc("delete_chat_conversation")
|
||||
@console_ns.doc(description="Delete a chat conversation")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
|
||||
@console_ns.response(204, "Conversation deleted successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(404, "Conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
def delete(self, session: Session, current_user: Account, app_model: App, conversation_id: UUID):
|
||||
conversation_id_str = str(conversation_id)
|
||||
|
||||
@console_ns.response(HTTPStatus.NO_CONTENT, "Conversation deleted successfully")
|
||||
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Authentication required")
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions")
|
||||
@console_ns.response(HTTPStatus.NOT_FOUND, "App or conversation not found")
|
||||
@console_account_admission(
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}),
|
||||
rbac_checks=(RBACCheck(RBACPermission.APP_EDIT, PlainApp()),),
|
||||
)
|
||||
def delete(self, request_context: RequestContext, app_id: UUID, conversation_id: UUID) -> tuple[str, HTTPStatus]:
|
||||
try:
|
||||
ConversationService.delete(app_model, conversation_id_str, current_user, session=session)
|
||||
except ConversationNotExistsError as caught_error:
|
||||
raise NotFound("Conversation Not Exists.") from caught_error
|
||||
|
||||
return "", 204
|
||||
|
||||
|
||||
def _get_conversation(session: Session, current_user: Account, app_model, conversation_id):
|
||||
conversation = session.scalar(
|
||||
sa.select(Conversation).where(Conversation.id == conversation_id, Conversation.app_id == app_model.id).limit(1)
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
|
||||
session.execute(
|
||||
sa.update(Conversation)
|
||||
.where(Conversation.id == conversation_id, Conversation.read_at.is_(None))
|
||||
# Keep updated_at unchanged when only marking a conversation as read.
|
||||
.values(
|
||||
{
|
||||
Conversation.read_at: naive_utc_now(),
|
||||
Conversation.read_account_id: current_user.id,
|
||||
Conversation.updated_at: Conversation.updated_at,
|
||||
}
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
session.refresh(conversation)
|
||||
|
||||
return conversation
|
||||
application_services().conversation_commands.delete_console(
|
||||
request_context, str(app_id), str(conversation_id), kind="chat"
|
||||
)
|
||||
except ConsoleAppNotFoundError as exc:
|
||||
raise AppNotFoundError(str(exc)) from exc
|
||||
except ConversationNotExistsError as exc:
|
||||
raise ConversationNotFoundHTTPError() from exc
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
@@ -588,7 +588,10 @@ def build_application_services(
|
||||
conversation_commands = ConversationCommandService(
|
||||
conversations=conversations, enqueue_delete_cleanup=ConversationService.enqueue_delete_cleanup
|
||||
)
|
||||
conversation_queries = ConversationQueryService(conversations=conversations, restore_inputs=restore_input_files)
|
||||
message_files = MessageFileResolver()
|
||||
conversation_queries = ConversationQueryService(
|
||||
conversations=conversations, restore_inputs=restore_input_files, files=message_files
|
||||
)
|
||||
conversation_variables = ConversationVariableRepository(session_factory=database_client)
|
||||
annotations = AnnotationRepository(session_factory=database_client)
|
||||
messages = MessageRepository(session_factory=database_client)
|
||||
@@ -750,7 +753,7 @@ def build_application_services(
|
||||
message_feedbacks=MessageFeedbackService(repository=messages),
|
||||
message_queries=MessageQueryService(
|
||||
messages=messages,
|
||||
files=MessageFileResolver(),
|
||||
files=message_files,
|
||||
extra_contents=ExecutionExtraContentReader(
|
||||
repository=SQLAlchemyExecutionExtraContentRepository(session_maker=database_client)
|
||||
),
|
||||
|
||||
Generated
+19
-9
@@ -2840,7 +2840,7 @@ Transcript audio to text for chat messages
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/chat-conversations
|
||||
Get chat conversations with pagination, filtering and summary
|
||||
Get chat conversations with pagination and filtering
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -2860,8 +2860,11 @@ Get chat conversations with pagination, filtering and summary
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [ConversationWithSummaryPagination](#conversationwithsummarypagination)<br> |
|
||||
| 400 | Invalid date range | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 401 | Authentication required | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | Insufficient permissions | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | App not found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 422 | Invalid query parameters | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
### [DELETE] /apps/{app_id}/chat-conversations/{conversation_id}
|
||||
@@ -2879,8 +2882,9 @@ Delete a chat conversation
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 204 | Conversation deleted successfully | |
|
||||
| 401 | Authentication required | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | Insufficient permissions | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | Conversation not found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | App or conversation not found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ConversationNotFoundError](#conversationnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/chat-conversations/{conversation_id}
|
||||
@@ -2898,8 +2902,9 @@ Get chat conversation details
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [ConversationDetail](#conversationdetail)<br> |
|
||||
| 401 | Authentication required | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | Insufficient permissions | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | Conversation not found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | App or conversation not found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ConversationNotFoundError](#conversationnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/chat-messages
|
||||
@@ -2983,8 +2988,11 @@ Get completion conversations with pagination and filtering
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [ConversationPagination](#conversationpagination)<br> |
|
||||
| 400 | Invalid date range | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 401 | Authentication required | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | Insufficient permissions | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | App not found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 422 | Invalid query parameters | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
### [DELETE] /apps/{app_id}/completion-conversations/{conversation_id}
|
||||
@@ -3002,8 +3010,9 @@ Delete a completion conversation
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 204 | Conversation deleted successfully | |
|
||||
| 401 | Authentication required | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | Insufficient permissions | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | Conversation not found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | App or conversation not found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ConversationNotFoundError](#conversationnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/completion-conversations/{conversation_id}
|
||||
@@ -3021,8 +3030,9 @@ Get completion conversation details with messages
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [ConversationMessageDetail](#conversationmessagedetail)<br> |
|
||||
| 401 | Authentication required | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 403 | Insufficient permissions | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | Conversation not found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| 404 | App or conversation not found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ConversationNotFoundError](#conversationnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/completion-messages
|
||||
@@ -22915,11 +22925,11 @@ A name is required when automatic naming is disabled.
|
||||
|
||||
#### ConversationNotFoundError
|
||||
|
||||
The conversation was not found for this account and app.
|
||||
The conversation was not found for this user and app.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| ConversationNotFoundError | | The conversation was not found for this account and app. | |
|
||||
| ConversationNotFoundError | | The conversation was not found for this user and app. | |
|
||||
|
||||
#### ConversationPagination
|
||||
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
"""Persist conversations within their end-user or installed-account owner chain."""
|
||||
"""Persist conversations within their end-user, installed-account, or Console app scope."""
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import Select, exists, or_, select
|
||||
from pydantic import JsonValue
|
||||
from sqlalchemy import Select, exists, func, or_, select, update
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.enums import ConversationFromSource, CreatorUserRole
|
||||
from models.model import App, Conversation, EndUser, InstalledApp, Message
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import escape_like_pattern
|
||||
from libs.pagination import paginate_query
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account
|
||||
from models.enums import ConversationFromSource, CreatorUserRole, InvokeFrom
|
||||
from models.model import App, AppMode, Conversation, EndUser, InstalledApp, Message, MessageAnnotation
|
||||
from models.web import PinnedConversation
|
||||
from repositories.app.console_repository import find_console_app
|
||||
from repositories.conversation_lifecycle import retire_conversation
|
||||
from repositories.message_projection import snapshot_console_message
|
||||
from services.app.console_service import ConsoleAppNotFoundError
|
||||
from services.entities.conversation_entities import (
|
||||
ConsoleChatConversationDetail,
|
||||
ConsoleChatConversationRecord,
|
||||
ConsoleCompletionConversationDetail,
|
||||
ConsoleCompletionConversationRecord,
|
||||
ConsoleConversationAccount,
|
||||
ConsoleConversationAnnotation,
|
||||
ConsoleConversationDetail,
|
||||
ConsoleConversationDetailSource,
|
||||
ConsoleConversationFilters,
|
||||
ConsoleConversationKind,
|
||||
ConsoleConversationMessage,
|
||||
ConsoleConversationPage,
|
||||
ConsoleConversationRecord,
|
||||
ConsoleConversationScope,
|
||||
ConversationDeletion,
|
||||
ConversationInputValue,
|
||||
ConversationListSource,
|
||||
@@ -24,6 +48,7 @@ from services.entities.conversation_entities import (
|
||||
InstalledAppConversationRef,
|
||||
InstalledAppConversationScope,
|
||||
)
|
||||
from services.errors.base import NoPermissionError
|
||||
from services.errors.conversation import ConversationNotExistsError, LastConversationNotExistsError
|
||||
from services.errors.message import MessageNotExistsError
|
||||
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
|
||||
@@ -129,6 +154,275 @@ class ConversationRepository:
|
||||
),
|
||||
)
|
||||
|
||||
def get_console_scope(
|
||||
self, context: RequestContext, app_id: str, *, kind: ConsoleConversationKind
|
||||
) -> ConsoleConversationScope:
|
||||
with self._session_factory() as session:
|
||||
app = self._get_console_app(session=session, context=context, app_id=app_id, kind=kind)
|
||||
account = session.get(Account, context.account_id)
|
||||
if account is None:
|
||||
raise NoPermissionError("The admitted account no longer exists.")
|
||||
assert account.timezone is not None
|
||||
return ConsoleConversationScope(
|
||||
app_id=app.id,
|
||||
tenant_id=app.tenant_id,
|
||||
app_mode=app.mode,
|
||||
timezone=account.timezone,
|
||||
)
|
||||
|
||||
def get_console_page(
|
||||
self, *, scope: ConsoleConversationScope, filters: ConsoleConversationFilters
|
||||
) -> ConsoleConversationPage:
|
||||
with self._session_factory() as session:
|
||||
app = find_console_app(session, workspace_id=scope.tenant_id, app_id=scope.app_id)
|
||||
if app is None:
|
||||
raise ConsoleAppNotFoundError("App not found.")
|
||||
if app.mode != scope.app_mode:
|
||||
raise ConsoleAppNotFoundError(
|
||||
f"App mode changed from '{scope.app_mode}' to '{app.mode}' after admission."
|
||||
)
|
||||
completion = app.mode == AppMode.COMPLETION
|
||||
statement = select(Conversation).where(Conversation.app_id == app.id, Conversation.is_deleted.is_(False))
|
||||
if completion:
|
||||
statement = statement.where(Conversation.mode == AppMode.COMPLETION)
|
||||
if filters.keyword:
|
||||
pattern = f"%{escape_like_pattern(filters.keyword)}%"
|
||||
statement = statement.join(Message, Message.conversation_id == Conversation.id)
|
||||
matches = [
|
||||
Message.query.ilike(pattern, escape="\\"),
|
||||
Message.answer.ilike(pattern, escape="\\"),
|
||||
]
|
||||
if not completion:
|
||||
statement = statement.outerjoin(EndUser, Conversation.from_end_user_id == EndUser.id)
|
||||
matches.extend(
|
||||
[
|
||||
Conversation.name.ilike(pattern, escape="\\"),
|
||||
Conversation.introduction.ilike(pattern, escape="\\"),
|
||||
EndUser.session_id.ilike(pattern, escape="\\"),
|
||||
]
|
||||
)
|
||||
statement = statement.group_by(Conversation.id)
|
||||
# Completion historically counts each matching message join row.
|
||||
statement = statement.where(or_(*matches))
|
||||
|
||||
column = (
|
||||
Conversation.created_at
|
||||
if completion or filters.sort_by in {"created_at", "-created_at"}
|
||||
else Conversation.updated_at
|
||||
)
|
||||
if filters.start is not None:
|
||||
statement = statement.where(column >= filters.start)
|
||||
if filters.end is not None:
|
||||
statement = statement.where(column <= filters.end)
|
||||
if filters.annotation_status == "annotated":
|
||||
statement = statement.join(
|
||||
MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
|
||||
).group_by(Conversation.id)
|
||||
elif filters.annotation_status == "not_annotated":
|
||||
statement = (
|
||||
statement.outerjoin(MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id)
|
||||
.group_by(Conversation.id)
|
||||
.having(func.count(MessageAnnotation.id) == 0)
|
||||
)
|
||||
if app.mode == AppMode.ADVANCED_CHAT:
|
||||
# SQL != also excludes NULL, matching the existing Console query.
|
||||
statement = statement.where(Conversation.invoke_from != InvokeFrom.DEBUGGER)
|
||||
statement = statement.order_by(
|
||||
column.desc() if completion or filters.sort_by.startswith("-") else column.asc()
|
||||
)
|
||||
page = paginate_query(statement, session=session, page=filters.page, per_page=filters.limit)
|
||||
return ConsoleConversationPage(
|
||||
tenant_id=app.tenant_id,
|
||||
page=page.page,
|
||||
per_page=page.per_page,
|
||||
total=page.total,
|
||||
has_next=page.has_next,
|
||||
items=tuple(
|
||||
self._console_record(conversation=item, session=session, completion=completion)
|
||||
for item in page.items
|
||||
),
|
||||
)
|
||||
|
||||
def get_console_detail(
|
||||
self, context: RequestContext, app_id: str, conversation_id: str, *, kind: ConsoleConversationKind
|
||||
) -> ConsoleConversationDetailSource:
|
||||
with self._session_factory() as session:
|
||||
app = self._get_console_app(session=session, context=context, app_id=app_id, kind=kind)
|
||||
conversation = self._get_console_conversation(
|
||||
session=session, app_id=app.id, conversation_id=conversation_id
|
||||
)
|
||||
common = self._console_detail_record(conversation=conversation, session=session)
|
||||
if kind == "completion":
|
||||
message = conversation.first_message_with_session(session=session)
|
||||
message_source = snapshot_console_message(message=message, session=session) if message else None
|
||||
return ConsoleConversationDetailSource(
|
||||
tenant_id=app.tenant_id,
|
||||
record=ConsoleCompletionConversationDetail(
|
||||
id=common.id,
|
||||
status=common.status,
|
||||
from_source=common.from_source,
|
||||
from_end_user_id=common.from_end_user_id,
|
||||
from_account_id=common.from_account_id,
|
||||
created_at=common.created_at,
|
||||
model_config=common.model_config,
|
||||
first_message=message_source.record if message_source is not None else None,
|
||||
),
|
||||
files=message_source.files if message_source is not None else (),
|
||||
)
|
||||
return ConsoleConversationDetailSource(
|
||||
tenant_id=app.tenant_id,
|
||||
record=ConsoleChatConversationDetail(
|
||||
id=common.id,
|
||||
status=common.status,
|
||||
from_source=common.from_source,
|
||||
from_end_user_id=common.from_end_user_id,
|
||||
from_account_id=common.from_account_id,
|
||||
created_at=common.created_at,
|
||||
model_config=common.model_config,
|
||||
updated_at=conversation.updated_at,
|
||||
annotated=conversation.annotated_with_session(session=session),
|
||||
introduction=conversation.introduction,
|
||||
message_count=conversation.message_count_with_session(session=session),
|
||||
user_feedback_stats=conversation.user_feedback_stats_with_session(session=session),
|
||||
admin_feedback_stats=conversation.admin_feedback_stats_with_session(session=session),
|
||||
),
|
||||
files=(),
|
||||
)
|
||||
|
||||
def mark_console_read(
|
||||
self, context: RequestContext, app_id: str, conversation_id: str, *, kind: ConsoleConversationKind
|
||||
) -> None:
|
||||
with self._session_factory.begin() as session:
|
||||
app = self._get_console_app(session=session, context=context, app_id=app_id, kind=kind)
|
||||
self._get_console_conversation(session=session, app_id=app.id, conversation_id=conversation_id)
|
||||
session.execute(
|
||||
update(Conversation)
|
||||
.where(
|
||||
Conversation.id == conversation_id,
|
||||
Conversation.app_id == app.id,
|
||||
Conversation.read_at.is_(None),
|
||||
)
|
||||
.values(
|
||||
{
|
||||
Conversation.read_at: naive_utc_now(),
|
||||
Conversation.read_account_id: context.account_id,
|
||||
# Marking a log read must not reorder the conversation list.
|
||||
Conversation.updated_at: Conversation.updated_at,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_console_conversation(*, session: Session, app_id: str, conversation_id: str) -> Conversation:
|
||||
# Console details include every actor's history and soft-deleted rows.
|
||||
conversation = session.scalar(
|
||||
select(Conversation).where(Conversation.id == conversation_id, Conversation.app_id == app_id)
|
||||
)
|
||||
if conversation is None:
|
||||
raise ConversationNotExistsError(f"Conversation {conversation_id} is unavailable for app {app_id}.")
|
||||
return conversation
|
||||
|
||||
@staticmethod
|
||||
def _console_detail_record(*, conversation: Conversation, session: Session) -> ConsoleConversationDetail:
|
||||
return ConsoleConversationDetail(
|
||||
id=conversation.id,
|
||||
status=conversation.status,
|
||||
from_source=conversation.from_source,
|
||||
from_end_user_id=conversation.from_end_user_id,
|
||||
from_account_id=conversation.from_account_id,
|
||||
created_at=conversation.created_at,
|
||||
model_config=deepcopy(cast(dict[str, JsonValue], conversation.model_config_with_session(session=session))),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _console_record(
|
||||
cls, *, conversation: Conversation, session: Session, completion: bool
|
||||
) -> ConsoleCompletionConversationRecord | ConsoleChatConversationRecord:
|
||||
# Keep established config/feedback/summary policies in their current
|
||||
# DB-only owners; this snapshot must not restore attachments or URLs.
|
||||
detail = cls._console_detail_record(conversation=conversation, session=session)
|
||||
common = ConsoleConversationRecord(
|
||||
id=detail.id,
|
||||
status=detail.status,
|
||||
from_source=detail.from_source,
|
||||
from_end_user_id=detail.from_end_user_id,
|
||||
from_end_user_session_id=conversation.from_end_user_session_id_with_session(session=session),
|
||||
from_account_id=detail.from_account_id,
|
||||
from_account_name=conversation.from_account_name_with_session(session=session),
|
||||
read_at=conversation.read_at,
|
||||
created_at=detail.created_at,
|
||||
updated_at=conversation.updated_at,
|
||||
model_config=detail.model_config,
|
||||
user_feedback_stats=conversation.user_feedback_stats_with_session(session=session),
|
||||
admin_feedback_stats=conversation.admin_feedback_stats_with_session(session=session),
|
||||
)
|
||||
if completion:
|
||||
annotation = conversation.annotation_with_session(session=session)
|
||||
account = annotation.account_with_session(session=session) if annotation is not None else None
|
||||
annotation_record = (
|
||||
ConsoleConversationAnnotation(
|
||||
id=annotation.id,
|
||||
question=annotation.question,
|
||||
content=annotation.content,
|
||||
account=(
|
||||
ConsoleConversationAccount(id=account.id, name=account.name, email=account.email)
|
||||
if account is not None
|
||||
else None
|
||||
),
|
||||
created_at=annotation.created_at,
|
||||
)
|
||||
if annotation is not None
|
||||
else None
|
||||
)
|
||||
message = conversation.first_message_with_session(session=session)
|
||||
first_message = (
|
||||
ConsoleConversationMessage(
|
||||
inputs=deepcopy(message._inputs),
|
||||
query=message.query,
|
||||
message=deepcopy(cast(JsonValue, message.message)),
|
||||
answer=message.answer,
|
||||
)
|
||||
if message is not None
|
||||
else None
|
||||
)
|
||||
return ConsoleCompletionConversationRecord(
|
||||
id=common.id,
|
||||
status=common.status,
|
||||
from_source=common.from_source,
|
||||
from_end_user_id=common.from_end_user_id,
|
||||
from_end_user_session_id=common.from_end_user_session_id,
|
||||
from_account_id=common.from_account_id,
|
||||
from_account_name=common.from_account_name,
|
||||
read_at=common.read_at,
|
||||
created_at=common.created_at,
|
||||
updated_at=common.updated_at,
|
||||
model_config=common.model_config,
|
||||
user_feedback_stats=common.user_feedback_stats,
|
||||
admin_feedback_stats=common.admin_feedback_stats,
|
||||
annotation=annotation_record,
|
||||
first_message=first_message,
|
||||
)
|
||||
return ConsoleChatConversationRecord(
|
||||
id=common.id,
|
||||
status=common.status,
|
||||
from_source=common.from_source,
|
||||
from_end_user_id=common.from_end_user_id,
|
||||
from_end_user_session_id=common.from_end_user_session_id,
|
||||
from_account_id=common.from_account_id,
|
||||
from_account_name=common.from_account_name,
|
||||
read_at=common.read_at,
|
||||
created_at=common.created_at,
|
||||
updated_at=common.updated_at,
|
||||
model_config=common.model_config,
|
||||
user_feedback_stats=common.user_feedback_stats,
|
||||
admin_feedback_stats=common.admin_feedback_stats,
|
||||
name=conversation.name,
|
||||
summary_or_query=conversation.summary_or_query_with_session(session=session),
|
||||
annotated=conversation.annotated_with_session(session=session),
|
||||
message_count=conversation.message_count_with_session(session=session),
|
||||
status_count=conversation.status_count_with_session(session=session),
|
||||
)
|
||||
|
||||
def get_name_source(self, *, ref: ConversationRef) -> ConversationNameSource:
|
||||
with self._session_factory() as session:
|
||||
app, conversation = self._resolve(session=session, ref=ref)
|
||||
@@ -167,6 +461,23 @@ class ConversationRepository:
|
||||
retired_workspace_ids=retired_workspace_ids,
|
||||
)
|
||||
|
||||
def delete_console(
|
||||
self, context: RequestContext, app_id: str, conversation_id: str, *, kind: ConsoleConversationKind
|
||||
) -> ConversationDeletion:
|
||||
with self._session_factory.begin() as session:
|
||||
app = self._get_console_app(session=session, context=context, app_id=app_id, kind=kind)
|
||||
# Console log queries see all users; deletion still requires the
|
||||
# current account's Console conversation, as in the original API.
|
||||
conversation = self._get_conversation(
|
||||
session=session, app_id=app.id, account_id=context.account_id, conversation_id=conversation_id
|
||||
)
|
||||
retired_workspace_ids = retire_conversation(app_model=app, conversation=conversation, session=session)
|
||||
return ConversationDeletion(
|
||||
tenant_id=app.tenant_id,
|
||||
conversation_id=conversation.id,
|
||||
retired_workspace_ids=retired_workspace_ids,
|
||||
)
|
||||
|
||||
def set_pinned(self, *, ref: ConversationRef, is_pinned: bool) -> None:
|
||||
with self._session_factory.begin() as session:
|
||||
if isinstance(ref, InstalledAppConversationRef):
|
||||
@@ -248,6 +559,23 @@ class ConversationRepository:
|
||||
)
|
||||
return result[0], result[1]
|
||||
|
||||
@staticmethod
|
||||
def _get_console_app(
|
||||
*, session: Session, context: RequestContext, app_id: str, kind: ConsoleConversationKind
|
||||
) -> App:
|
||||
app = find_console_app(session, workspace_id=context.active_workspace_id, app_id=app_id)
|
||||
allowed_modes = (
|
||||
{AppMode.COMPLETION}
|
||||
if kind == "completion"
|
||||
else {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}
|
||||
)
|
||||
if app is None:
|
||||
raise ConsoleAppNotFoundError("App not found.")
|
||||
if app.mode not in allowed_modes:
|
||||
mode_values = sorted(mode.value for mode in allowed_modes)
|
||||
raise ConsoleAppNotFoundError(f"App mode is not in the supported list: {mode_values}")
|
||||
return app
|
||||
|
||||
@staticmethod
|
||||
def _get_app(*, session: Session, installed_app: InstalledAppRef) -> App:
|
||||
app = session.scalar(
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Snapshot message data in the caller's session before file or extra-content I/O."""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import cast
|
||||
|
||||
from pydantic import JsonValue
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.account import Account
|
||||
from models.model import Message, MessageAgentThought, MessageFile
|
||||
from services.entities.message_entities import (
|
||||
ConsoleMessageFeedbackRecord,
|
||||
ConsoleMessageRecord,
|
||||
MessageAccountRecord,
|
||||
MessageAgentThoughtRecord,
|
||||
MessageAnnotationHitRecord,
|
||||
MessageAnnotationRecord,
|
||||
MessageFileReference,
|
||||
MessageInputValue,
|
||||
MessageRating,
|
||||
MessageRecord,
|
||||
MessageSource,
|
||||
)
|
||||
|
||||
|
||||
def snapshot_message(*, message: Message, session: Session) -> MessageSource[MessageRecord]:
|
||||
"""Borrow the caller's session and return detached data plus raw file references."""
|
||||
return _message_source(record=_message_record(message=message, session=session), session=session)
|
||||
|
||||
|
||||
def snapshot_console_message(*, message: Message, session: Session) -> MessageSource[ConsoleMessageRecord]:
|
||||
"""Materialize Console fields without restoring files or signing URLs."""
|
||||
return _message_source(record=_console_message_record(message=message, session=session), session=session)
|
||||
|
||||
|
||||
def _message_source[RecordT: MessageRecord](*, record: RecordT, session: Session) -> MessageSource[RecordT]:
|
||||
"""Snapshot file references without restoring files or signing URLs."""
|
||||
files = session.scalars(select(MessageFile).where(MessageFile.message_id == record.id))
|
||||
return MessageSource(
|
||||
record=record,
|
||||
files=tuple(
|
||||
MessageFileReference(
|
||||
id=file.id,
|
||||
type=file.type.value,
|
||||
transfer_method=file.transfer_method.value,
|
||||
url=file.url,
|
||||
upload_file_id=file.upload_file_id,
|
||||
belongs_to=file.belongs_to.value if file.belongs_to is not None else None,
|
||||
)
|
||||
for file in files
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _message_record(*, message: Message, session: Session) -> MessageRecord:
|
||||
# Snapshot stored JSON; restoration and URL signing belong to the query
|
||||
# service's file adapter after this database session has closed.
|
||||
inputs = deepcopy(cast(dict[str, MessageInputValue], message._inputs))
|
||||
feedback = message.user_feedback_with_session(session=session)
|
||||
thoughts = [_thought_record(thought) for thought in message.agent_thoughts_with_session(session=session)]
|
||||
return MessageRecord(
|
||||
id=message.id,
|
||||
conversation_id=message.conversation_id,
|
||||
parent_message_id=message.parent_message_id,
|
||||
inputs=inputs,
|
||||
query=message.query,
|
||||
answer=message.answer,
|
||||
feedback=MessageRating(rating=feedback.rating.value) if feedback is not None else None,
|
||||
retriever_resources=cast(list[dict[str, JsonValue]] | None, message.retriever_resources),
|
||||
created_at=message.created_at,
|
||||
agent_thoughts=thoughts,
|
||||
message_files=[],
|
||||
message_tokens=message.message_tokens,
|
||||
answer_tokens=message.answer_tokens,
|
||||
provider_response_latency=message.provider_response_latency,
|
||||
total_price=message.total_price,
|
||||
currency=message.currency,
|
||||
status=message.status.value,
|
||||
error=message.error,
|
||||
metadata=cast(JsonValue, message.message_metadata_dict),
|
||||
)
|
||||
|
||||
|
||||
def _thought_record(thought: MessageAgentThought) -> MessageAgentThoughtRecord:
|
||||
return MessageAgentThoughtRecord(
|
||||
id=thought.id,
|
||||
message_id=thought.message_id,
|
||||
message_chain_id=thought.message_chain_id,
|
||||
position=thought.position,
|
||||
thought=thought.thought,
|
||||
answer=thought.answer,
|
||||
tool=thought.tool,
|
||||
tool_labels=cast(JsonValue, thought.tool_labels),
|
||||
tool_input=thought.tool_input,
|
||||
created_at=thought.created_at,
|
||||
observation=thought.observation,
|
||||
files=cast(list[str], thought.files),
|
||||
)
|
||||
|
||||
|
||||
def _account_record(account: Account | None) -> MessageAccountRecord | None:
|
||||
return MessageAccountRecord(id=account.id, name=account.name, email=account.email) if account else None
|
||||
|
||||
|
||||
def _console_message_record(*, message: Message, session: Session) -> ConsoleMessageRecord:
|
||||
record = _message_record(message=message, session=session)
|
||||
annotation = message.annotation_with_session(session=session)
|
||||
annotation_hit = message.annotation_hit_history_with_session(session=session)
|
||||
return ConsoleMessageRecord(
|
||||
id=record.id,
|
||||
conversation_id=record.conversation_id,
|
||||
parent_message_id=record.parent_message_id,
|
||||
inputs=record.inputs,
|
||||
query=record.query,
|
||||
answer=record.answer,
|
||||
feedback=record.feedback,
|
||||
retriever_resources=record.retriever_resources or [],
|
||||
created_at=record.created_at,
|
||||
agent_thoughts=record.agent_thoughts,
|
||||
message_files=record.message_files,
|
||||
message_tokens=record.message_tokens,
|
||||
answer_tokens=record.answer_tokens,
|
||||
provider_response_latency=record.provider_response_latency,
|
||||
total_price=record.total_price,
|
||||
currency=record.currency,
|
||||
status=record.status,
|
||||
error=record.error,
|
||||
metadata=record.metadata,
|
||||
message=deepcopy(cast(JsonValue, message.message)),
|
||||
from_source=message.from_source,
|
||||
from_end_user_id=message.from_end_user_id,
|
||||
from_account_id=message.from_account_id,
|
||||
workflow_run_id=message.workflow_run_id,
|
||||
feedbacks=[
|
||||
ConsoleMessageFeedbackRecord(
|
||||
rating=feedback.rating.value,
|
||||
content=feedback.content,
|
||||
from_source=feedback.from_source.value,
|
||||
from_end_user_id=feedback.from_end_user_id,
|
||||
from_account=_account_record(feedback.from_account_with_session(session=session)),
|
||||
)
|
||||
for feedback in message.feedbacks_with_session(session=session)
|
||||
],
|
||||
annotation=MessageAnnotationRecord(
|
||||
id=annotation.id,
|
||||
question=annotation.question,
|
||||
content=annotation.content,
|
||||
account=_account_record(annotation.account_with_session(session=session)),
|
||||
created_at=annotation.created_at,
|
||||
)
|
||||
if annotation
|
||||
else None,
|
||||
annotation_hit_history=MessageAnnotationHitRecord(
|
||||
id=annotation_hit.id,
|
||||
annotation_create_account=_account_record(
|
||||
annotation_hit.annotation_create_account_with_session(session=session)
|
||||
),
|
||||
created_at=annotation_hit.created_at,
|
||||
)
|
||||
if annotation_hit
|
||||
else None,
|
||||
)
|
||||
@@ -21,21 +21,14 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from core.memory.token_buffer_memory import PreparedHistory, TokenBufferMemory
|
||||
from models import Account, App, AppModelConfig, Conversation, EndUser, Message, MessageFile
|
||||
from models.enums import FeedbackFromSource, FeedbackRating
|
||||
from models.model import AppMode, InstalledApp, MessageAgentThought, MessageFeedback, load_annotation_reply_config
|
||||
from models.model import AppMode, InstalledApp, MessageFeedback, load_annotation_reply_config
|
||||
from repositories.message_projection import snapshot_console_message, snapshot_message
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.entities.message_entities import (
|
||||
ConsoleMessageFeedbackRecord,
|
||||
ConsoleMessageRecord,
|
||||
MessageAccount,
|
||||
MessageAccountRecord,
|
||||
MessageActor,
|
||||
MessageAgentThoughtRecord,
|
||||
MessageAnnotationHitRecord,
|
||||
MessageAnnotationRecord,
|
||||
MessageFileReference,
|
||||
MessageInputValue,
|
||||
MessagePage,
|
||||
MessageRating,
|
||||
MessageRecord,
|
||||
MessageSource,
|
||||
)
|
||||
@@ -99,8 +92,7 @@ class MessageRepository:
|
||||
limit=limit,
|
||||
has_more=has_more,
|
||||
data=tuple(
|
||||
self._to_source(record=self._to_message_record(message=message, session=session), session=session)
|
||||
for message in reversed(messages[:limit])
|
||||
snapshot_message(message=message, session=session) for message in reversed(messages[:limit])
|
||||
),
|
||||
)
|
||||
|
||||
@@ -146,10 +138,7 @@ class MessageRepository:
|
||||
limit=limit,
|
||||
has_more=has_more,
|
||||
data=tuple(
|
||||
self._to_source(
|
||||
record=self._to_console_message_record(message=message, session=session), session=session
|
||||
)
|
||||
for message in reversed(messages)
|
||||
snapshot_console_message(message=message, session=session) for message in reversed(messages)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -174,27 +163,7 @@ class MessageRepository:
|
||||
message = session.scalar(select(Message).where(Message.id == message_id, Message.app_id == app_id))
|
||||
if message is None:
|
||||
raise MessageNotExistsError(f"Message {message_id} is unavailable for app {app_id}")
|
||||
record = self._to_console_message_record(message=message, session=session)
|
||||
return self._to_source(record=record, session=session)
|
||||
|
||||
@staticmethod
|
||||
def _to_source[RecordT: MessageRecord](*, record: RecordT, session: Session) -> MessageSource[RecordT]:
|
||||
"""Snapshot file references without restoring files or signing URLs."""
|
||||
files = session.scalars(select(MessageFile).where(MessageFile.message_id == record.id))
|
||||
return MessageSource(
|
||||
record=record,
|
||||
files=tuple(
|
||||
MessageFileReference(
|
||||
id=file.id,
|
||||
type=file.type.value,
|
||||
transfer_method=file.transfer_method.value,
|
||||
url=file.url,
|
||||
upload_file_id=file.upload_file_id,
|
||||
belongs_to=file.belongs_to.value if file.belongs_to is not None else None,
|
||||
)
|
||||
for file in files
|
||||
),
|
||||
)
|
||||
return snapshot_console_message(message=message, session=session)
|
||||
|
||||
def _require_page_app(
|
||||
self,
|
||||
@@ -657,115 +626,3 @@ class MessageRepository:
|
||||
Message.from_end_user_id == end_user_id,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_message_record(*, message: Message, session: Session) -> MessageRecord:
|
||||
# Snapshot stored JSON; restoration and URL signing belong to the query
|
||||
# service's file adapter after this database session has closed.
|
||||
inputs = deepcopy(cast(dict[str, MessageInputValue], message._inputs))
|
||||
feedback = message.user_feedback_with_session(session=session)
|
||||
thoughts = [
|
||||
MessageRepository._thought_record(thought)
|
||||
for thought in message.agent_thoughts_with_session(session=session)
|
||||
]
|
||||
return MessageRecord(
|
||||
id=message.id,
|
||||
conversation_id=message.conversation_id,
|
||||
parent_message_id=message.parent_message_id,
|
||||
inputs=inputs,
|
||||
query=message.query,
|
||||
answer=message.answer,
|
||||
feedback=MessageRating(rating=feedback.rating.value) if feedback is not None else None,
|
||||
retriever_resources=cast(list[dict[str, JsonValue]] | None, message.retriever_resources),
|
||||
created_at=message.created_at,
|
||||
agent_thoughts=thoughts,
|
||||
message_files=[],
|
||||
message_tokens=message.message_tokens,
|
||||
answer_tokens=message.answer_tokens,
|
||||
provider_response_latency=message.provider_response_latency,
|
||||
total_price=message.total_price,
|
||||
currency=message.currency,
|
||||
status=message.status.value,
|
||||
error=message.error,
|
||||
metadata=cast(JsonValue, message.message_metadata_dict),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _thought_record(thought: MessageAgentThought) -> MessageAgentThoughtRecord:
|
||||
return MessageAgentThoughtRecord(
|
||||
id=thought.id,
|
||||
message_id=thought.message_id,
|
||||
message_chain_id=thought.message_chain_id,
|
||||
position=thought.position,
|
||||
thought=thought.thought,
|
||||
answer=thought.answer,
|
||||
tool=thought.tool,
|
||||
tool_labels=cast(JsonValue, thought.tool_labels),
|
||||
tool_input=thought.tool_input,
|
||||
created_at=thought.created_at,
|
||||
observation=thought.observation,
|
||||
files=cast(list[str], thought.files),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _account_record(account: Account | None) -> MessageAccountRecord | None:
|
||||
return MessageAccountRecord(id=account.id, name=account.name, email=account.email) if account else None
|
||||
|
||||
def _to_console_message_record(self, *, message: Message, session: Session) -> ConsoleMessageRecord:
|
||||
record = self._to_message_record(message=message, session=session)
|
||||
annotation = message.annotation_with_session(session=session)
|
||||
annotation_hit = message.annotation_hit_history_with_session(session=session)
|
||||
return ConsoleMessageRecord(
|
||||
id=record.id,
|
||||
conversation_id=record.conversation_id,
|
||||
parent_message_id=record.parent_message_id,
|
||||
inputs=record.inputs,
|
||||
query=record.query,
|
||||
answer=record.answer,
|
||||
feedback=record.feedback,
|
||||
retriever_resources=record.retriever_resources or [],
|
||||
created_at=record.created_at,
|
||||
agent_thoughts=record.agent_thoughts,
|
||||
message_files=record.message_files,
|
||||
message_tokens=record.message_tokens,
|
||||
answer_tokens=record.answer_tokens,
|
||||
provider_response_latency=record.provider_response_latency,
|
||||
total_price=record.total_price,
|
||||
currency=record.currency,
|
||||
status=record.status,
|
||||
error=record.error,
|
||||
metadata=record.metadata,
|
||||
message=deepcopy(cast(JsonValue, message.message)),
|
||||
from_source=message.from_source,
|
||||
from_end_user_id=message.from_end_user_id,
|
||||
from_account_id=message.from_account_id,
|
||||
workflow_run_id=message.workflow_run_id,
|
||||
feedbacks=[
|
||||
ConsoleMessageFeedbackRecord(
|
||||
rating=feedback.rating.value,
|
||||
content=feedback.content,
|
||||
from_source=feedback.from_source.value,
|
||||
from_end_user_id=feedback.from_end_user_id,
|
||||
from_account=self._account_record(feedback.from_account_with_session(session=session)),
|
||||
)
|
||||
for feedback in message.feedbacks_with_session(session=session)
|
||||
],
|
||||
annotation=MessageAnnotationRecord(
|
||||
id=annotation.id,
|
||||
question=annotation.question,
|
||||
content=annotation.content,
|
||||
account=self._account_record(annotation.account_with_session(session=session)),
|
||||
created_at=annotation.created_at,
|
||||
)
|
||||
if annotation
|
||||
else None,
|
||||
annotation_hit_history=MessageAnnotationHitRecord(
|
||||
id=annotation_hit.id,
|
||||
annotation_create_account=self._account_record(
|
||||
annotation_hit.annotation_create_account_with_session(session=session)
|
||||
),
|
||||
created_at=annotation_hit.created_at,
|
||||
)
|
||||
if annotation_hit
|
||||
else None,
|
||||
)
|
||||
@@ -2,10 +2,21 @@
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from services.entities.conversation_entities import ConversationDeletion, ConversationRef
|
||||
from machinery.context import RequestContext
|
||||
from services.entities.conversation_entities import ConsoleConversationKind, ConversationDeletion, ConversationRef
|
||||
|
||||
|
||||
class ConversationCommandStore(Protocol):
|
||||
def mark_console_read(
|
||||
self, context: RequestContext, app_id: str, conversation_id: str, *, kind: ConsoleConversationKind
|
||||
) -> None: ...
|
||||
|
||||
def delete_console(
|
||||
self, context: RequestContext, app_id: str, conversation_id: str, *, kind: ConsoleConversationKind
|
||||
) -> ConversationDeletion:
|
||||
"""Delete the account's Console conversation within the admitted workspace."""
|
||||
...
|
||||
|
||||
def delete(self, *, ref: ConversationRef) -> ConversationDeletion:
|
||||
"""Commit soft deletion and workspace retirement before returning cleanup data."""
|
||||
...
|
||||
@@ -24,6 +35,22 @@ class ConversationCommandService:
|
||||
self._conversations: ConversationCommandStore = conversations
|
||||
self._enqueue_delete_cleanup: ConversationCleanup = enqueue_delete_cleanup
|
||||
|
||||
def mark_console_read(
|
||||
self, context: RequestContext, app_id: str, conversation_id: str, *, kind: ConsoleConversationKind
|
||||
) -> None:
|
||||
"""Record the legacy Console read receipt after detail rendering succeeds."""
|
||||
self._conversations.mark_console_read(context, app_id, conversation_id, kind=kind)
|
||||
|
||||
def delete_console(
|
||||
self, context: RequestContext, app_id: str, conversation_id: str, *, kind: ConsoleConversationKind
|
||||
) -> None:
|
||||
deleted = self._conversations.delete_console(context, app_id, conversation_id, kind=kind)
|
||||
self._enqueue_delete_cleanup(
|
||||
tenant_id=deleted.tenant_id,
|
||||
conversation_id=deleted.conversation_id,
|
||||
retired_workspace_ids=deleted.retired_workspace_ids,
|
||||
)
|
||||
|
||||
def delete(self, *, ref: ConversationRef) -> None:
|
||||
deleted = self._conversations.delete(ref=ref)
|
||||
# The retirement and soft-delete commit must precede physical cleanup.
|
||||
|
||||
@@ -4,16 +4,38 @@ from collections.abc import Callable, Mapping
|
||||
from dataclasses import replace
|
||||
from typing import Protocol
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.entities.conversation_entities import (
|
||||
ConsoleChatConversationDetail,
|
||||
ConsoleCompletionConversationDetail,
|
||||
ConsoleCompletionConversationRecord,
|
||||
ConsoleConversationDetailSource,
|
||||
ConsoleConversationFilters,
|
||||
ConsoleConversationKind,
|
||||
ConsoleConversationPage,
|
||||
ConsoleConversationScope,
|
||||
ConversationInputValue,
|
||||
ConversationListSource,
|
||||
ConversationPage,
|
||||
ConversationScope,
|
||||
ConversationSort,
|
||||
)
|
||||
from services.message_query_service import MessageFiles
|
||||
|
||||
|
||||
class ConversationQueryStore(Protocol):
|
||||
def get_console_detail(
|
||||
self, context: RequestContext, app_id: str, conversation_id: str, *, kind: ConsoleConversationKind
|
||||
) -> ConsoleConversationDetailSource: ...
|
||||
|
||||
def get_console_scope(
|
||||
self, context: RequestContext, app_id: str, *, kind: ConsoleConversationKind
|
||||
) -> ConsoleConversationScope: ...
|
||||
|
||||
def get_console_page(
|
||||
self, *, scope: ConsoleConversationScope, filters: ConsoleConversationFilters
|
||||
) -> ConsoleConversationPage: ...
|
||||
|
||||
def get_page(
|
||||
self,
|
||||
*,
|
||||
@@ -33,9 +55,57 @@ class ConversationInputRestorer(Protocol):
|
||||
|
||||
|
||||
class ConversationQueryService:
|
||||
def __init__(self, *, conversations: ConversationQueryStore, restore_inputs: ConversationInputRestorer) -> None:
|
||||
def __init__(
|
||||
self, *, conversations: ConversationQueryStore, restore_inputs: ConversationInputRestorer, files: MessageFiles
|
||||
) -> None:
|
||||
self._conversations = conversations
|
||||
self._restore_inputs = restore_inputs
|
||||
self._files = files
|
||||
|
||||
def get_console_detail(
|
||||
self, context: RequestContext, app_id: str, conversation_id: str, *, kind: ConsoleConversationKind
|
||||
) -> ConsoleCompletionConversationDetail | ConsoleChatConversationDetail:
|
||||
source = self._conversations.get_console_detail(context, app_id, conversation_id, kind=kind)
|
||||
record = source.record
|
||||
if isinstance(record, ConsoleCompletionConversationDetail) and record.first_message is not None:
|
||||
message = record.first_message
|
||||
files = self._files.resolve(
|
||||
tenant_id=source.tenant_id, inputs=message.inputs, answer=message.answer, files=source.files
|
||||
)
|
||||
record = replace(
|
||||
record,
|
||||
first_message=replace(message, inputs=files.inputs, answer=files.answer, message_files=files.files),
|
||||
)
|
||||
return record
|
||||
|
||||
def get_console_scope(
|
||||
self, context: RequestContext, app_id: str, *, kind: ConsoleConversationKind
|
||||
) -> ConsoleConversationScope:
|
||||
return self._conversations.get_console_scope(context, app_id, kind=kind)
|
||||
|
||||
def get_console_page(
|
||||
self, *, scope: ConsoleConversationScope, filters: ConsoleConversationFilters
|
||||
) -> ConsoleConversationPage:
|
||||
page = self._conversations.get_console_page(scope=scope, filters=filters)
|
||||
# Completion logs include the first message's file inputs. Restore them
|
||||
# only after every database-backed response field has been materialized.
|
||||
return replace(
|
||||
page,
|
||||
items=tuple(
|
||||
replace(
|
||||
record,
|
||||
first_message=replace(
|
||||
record.first_message,
|
||||
inputs=self._restore_inputs(
|
||||
inputs=record.first_message.inputs, tenant_resolver=lambda: page.tenant_id
|
||||
),
|
||||
),
|
||||
)
|
||||
if isinstance(record, ConsoleCompletionConversationRecord) and record.first_message is not None
|
||||
else record
|
||||
for record in page.items
|
||||
),
|
||||
)
|
||||
|
||||
def get_page(
|
||||
self,
|
||||
|
||||
@@ -5,7 +5,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from models import Account
|
||||
from models.model import App, Conversation, EndUser
|
||||
from repositories.conversation_lifecycle import retire_conversation
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
from tasks.delete_conversation_task import delete_conversation_related_data
|
||||
@@ -36,31 +35,6 @@ class ConversationService:
|
||||
|
||||
return conversation
|
||||
|
||||
@classmethod
|
||||
def delete(cls, app_model: App, conversation_id: str, user: Account | EndUser | None, *, session: Session) -> None:
|
||||
"""
|
||||
Delete a conversation only if it belongs to the given user and app context.
|
||||
|
||||
Conversation deletion is the product lifecycle boundary for its
|
||||
Workspace. Physical collection happens only after the retire commit.
|
||||
|
||||
Raises:
|
||||
ConversationNotExistsError: When the conversation is not visible to the current user.
|
||||
"""
|
||||
conversation = cls.get_conversation(app_model, conversation_id, user, session=session)
|
||||
tenant_id = app_model.tenant_id
|
||||
try:
|
||||
retired_workspace_ids = retire_conversation(app_model=app_model, conversation=conversation, session=session)
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
cls.enqueue_delete_cleanup(
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=conversation_id,
|
||||
retired_workspace_ids=retired_workspace_ids,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def enqueue_delete_cleanup(*, tenant_id: str, conversation_id: str, retired_workspace_ids: tuple[str, ...]) -> None:
|
||||
"""Dispatch resource collection only after the lifecycle transaction commits."""
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Literal
|
||||
from pydantic import JsonValue
|
||||
|
||||
from graphon.file import File
|
||||
from services.entities.message_entities import ConsoleMessageRecord, MessageFileReference
|
||||
from services.installed_app_access_service import InstalledAppRef
|
||||
|
||||
type ConversationInputValue = JsonValue | File | list[File]
|
||||
@@ -76,3 +77,126 @@ class ConversationDeletion:
|
||||
tenant_id: str
|
||||
conversation_id: str
|
||||
retired_workspace_ids: tuple[str, ...]
|
||||
|
||||
|
||||
type ConsoleConversationKind = Literal["completion", "chat"]
|
||||
type ConversationAnnotationStatus = Literal["annotated", "not_annotated", "all"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleConversationScope:
|
||||
app_id: str
|
||||
tenant_id: str
|
||||
app_mode: str
|
||||
timezone: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleConversationFilters:
|
||||
keyword: str | None
|
||||
start: datetime | None
|
||||
end: datetime | None
|
||||
annotation_status: ConversationAnnotationStatus
|
||||
sort_by: ConversationSort
|
||||
page: int
|
||||
limit: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleConversationAccount:
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleConversationAnnotation:
|
||||
id: str
|
||||
question: str | None
|
||||
content: str
|
||||
account: ConsoleConversationAccount | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleConversationMessage:
|
||||
# File reconstruction belongs after the repository's read session closes.
|
||||
inputs: dict[str, ConversationInputValue]
|
||||
query: str
|
||||
message: JsonValue
|
||||
answer: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleConversationRecord:
|
||||
id: str
|
||||
status: str
|
||||
from_source: str
|
||||
from_end_user_id: str | None
|
||||
from_end_user_session_id: str | None
|
||||
from_account_id: str | None
|
||||
from_account_name: str | None
|
||||
read_at: datetime | None
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
model_config: dict[str, JsonValue]
|
||||
user_feedback_stats: dict[str, int]
|
||||
admin_feedback_stats: dict[str, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleCompletionConversationRecord(ConsoleConversationRecord):
|
||||
annotation: ConsoleConversationAnnotation | None
|
||||
first_message: ConsoleConversationMessage | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleChatConversationRecord(ConsoleConversationRecord):
|
||||
name: str
|
||||
summary_or_query: str
|
||||
annotated: bool
|
||||
message_count: int
|
||||
status_count: dict[str, int] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleConversationPage:
|
||||
tenant_id: str
|
||||
page: int
|
||||
per_page: int
|
||||
total: int
|
||||
has_next: bool
|
||||
items: tuple[ConsoleCompletionConversationRecord | ConsoleChatConversationRecord, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleConversationDetail:
|
||||
id: str
|
||||
status: str
|
||||
from_source: str
|
||||
from_end_user_id: str | None
|
||||
from_account_id: str | None
|
||||
created_at: datetime | None
|
||||
model_config: dict[str, JsonValue]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleChatConversationDetail(ConsoleConversationDetail):
|
||||
updated_at: datetime | None
|
||||
annotated: bool
|
||||
introduction: str | None
|
||||
message_count: int
|
||||
user_feedback_stats: dict[str, int]
|
||||
admin_feedback_stats: dict[str, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleCompletionConversationDetail(ConsoleConversationDetail):
|
||||
first_message: ConsoleMessageRecord | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleConversationDetailSource:
|
||||
tenant_id: str
|
||||
record: ConsoleChatConversationDetail | ConsoleCompletionConversationDetail
|
||||
files: tuple[MessageFileReference, ...]
|
||||
+72
-28
@@ -1,31 +1,34 @@
|
||||
"""Execute Console read-marker writes against PostgreSQL without replacing the clock."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from controllers.console.app.conversation import _get_conversation
|
||||
from models.enums import ConversationFromSource
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from machinery.context import RequestContext
|
||||
from models.enums import ConversationFromSource, ConversationStatus
|
||||
from models.model import AppMode, Conversation
|
||||
from repositories.conversation_repository import ConversationRepository
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from tests.e2e_tests.containers.controllers.console.helpers import (
|
||||
create_console_account_and_tenant,
|
||||
create_console_app,
|
||||
)
|
||||
|
||||
|
||||
def test_get_conversation_mark_read_keeps_updated_at_unchanged(
|
||||
def test_console_detail_then_mark_preserves_updated_at_and_the_first_reader(
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
) -> 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)
|
||||
|
||||
original_updated_at = datetime(2026, 2, 8, 0, 0, 0)
|
||||
original_updated_at = datetime(2020, 2, 8)
|
||||
conversation = Conversation(
|
||||
app_id=app.id,
|
||||
name="read timestamp test",
|
||||
_inputs={},
|
||||
status="normal",
|
||||
status=ConversationStatus.NORMAL,
|
||||
mode=AppMode.CHAT,
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
@@ -33,29 +36,70 @@ def test_get_conversation_mark_read_keeps_updated_at_unchanged(
|
||||
conversation.updated_at = original_updated_at
|
||||
db_session_with_containers.add(conversation)
|
||||
db_session_with_containers.commit()
|
||||
context = RequestContext(
|
||||
request_id="postgres-read", trace_id=None, account_id=account.id, active_workspace_id=tenant.id
|
||||
)
|
||||
app_id, conversation_id = app.id, conversation.id
|
||||
sessions = sessionmaker(bind=db_session_with_containers.get_bind(), expire_on_commit=False)
|
||||
db_session_with_containers.rollback()
|
||||
repository = ConversationRepository(session_factory=sessions)
|
||||
|
||||
read_at = datetime(2026, 2, 9, 0, 0, 0)
|
||||
loaded = repository.get_console_detail(context, app_id, conversation_id, kind="chat")
|
||||
assert loaded.record.id == conversation_id
|
||||
with sessions() as session:
|
||||
stored = session.get_one(Conversation, conversation_id)
|
||||
assert stored.read_at is None
|
||||
assert stored.read_account_id is None
|
||||
assert stored.updated_at == original_updated_at
|
||||
|
||||
with patch(
|
||||
"controllers.console.app.conversation.naive_utc_now",
|
||||
return_value=read_at,
|
||||
autospec=True,
|
||||
):
|
||||
loaded = _get_conversation(db_session_with_containers, account, app, conversation.id)
|
||||
before = naive_utc_now()
|
||||
repository.mark_console_read(context, app_id, conversation_id, kind="chat")
|
||||
after = naive_utc_now()
|
||||
with sessions() as session:
|
||||
stored = session.get_one(Conversation, conversation_id)
|
||||
first_read_at = stored.read_at
|
||||
assert first_read_at is not None
|
||||
assert before <= first_read_at <= after
|
||||
assert stored.read_account_id == context.account_id
|
||||
assert stored.updated_at == original_updated_at
|
||||
|
||||
db_session_with_containers.refresh(conversation)
|
||||
|
||||
assert loaded.id == conversation.id
|
||||
assert conversation.read_at == read_at
|
||||
assert conversation.read_account_id == account.id
|
||||
assert conversation.updated_at == original_updated_at
|
||||
second_reader = context._replace(account_id=str(uuid4()))
|
||||
repository.mark_console_read(second_reader, app_id, conversation_id, kind="chat")
|
||||
with sessions() as session:
|
||||
stored = session.get_one(Conversation, conversation_id)
|
||||
assert stored.read_at == first_read_at
|
||||
assert stored.read_account_id == context.account_id
|
||||
assert stored.updated_at == original_updated_at
|
||||
|
||||
|
||||
def test_get_conversation_raises_not_found_for_missing_conversation(
|
||||
def test_console_missing_detail_or_mark_does_not_mark_another_conversation(
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
) -> 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)
|
||||
|
||||
with pytest.raises(NotFound):
|
||||
_get_conversation(db_session_with_containers, account, app, "00000000-0000-0000-0000-000000000000")
|
||||
conversation = Conversation(
|
||||
app_id=app.id,
|
||||
name="Untouched conversation",
|
||||
_inputs={},
|
||||
mode=AppMode.CHAT,
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
)
|
||||
db_session_with_containers.add(conversation)
|
||||
db_session_with_containers.commit()
|
||||
context = RequestContext(
|
||||
request_id="postgres-read", trace_id=None, account_id=account.id, active_workspace_id=tenant.id
|
||||
)
|
||||
app_id, conversation_id = app.id, conversation.id
|
||||
sessions = sessionmaker(bind=db_session_with_containers.get_bind(), expire_on_commit=False)
|
||||
db_session_with_containers.rollback()
|
||||
repository = ConversationRepository(session_factory=sessions)
|
||||
missing_id = str(uuid4())
|
||||
with pytest.raises(ConversationNotExistsError):
|
||||
repository.get_console_detail(context, app_id, missing_id, kind="chat")
|
||||
with pytest.raises(ConversationNotExistsError):
|
||||
repository.mark_console_read(context, app_id, missing_id, kind="chat")
|
||||
with sessions() as session:
|
||||
stored = session.get_one(Conversation, conversation_id)
|
||||
assert stored.read_at is None
|
||||
assert stored.read_account_id is None
|
||||
@@ -2,11 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
@@ -234,112 +232,3 @@ class TestConversationServiceRetrieval:
|
||||
|
||||
# Assert
|
||||
assert result == conversation
|
||||
|
||||
@patch("services.conversation_service.delete_conversation_related_data")
|
||||
def test_delete_conversation(self, mock_delete_task, db_session_with_containers: Session):
|
||||
"""
|
||||
Test conversation deletion with async cleanup.
|
||||
|
||||
Deletion is a two-step process:
|
||||
1. Immediately hide the conversation with a durable soft-delete marker
|
||||
2. Trigger async background task to clean up related data
|
||||
(messages, annotations, vector embeddings, file uploads)
|
||||
"""
|
||||
# Arrange - Set up test data
|
||||
app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account(
|
||||
db_session_with_containers
|
||||
)
|
||||
conversation = ConversationServiceIntegrationTestDataFactory.create_conversation(
|
||||
db_session_with_containers,
|
||||
app_model,
|
||||
user,
|
||||
)
|
||||
conversation_id = conversation.id
|
||||
|
||||
# Act - Delete the conversation
|
||||
ConversationService.delete(
|
||||
app_model=app_model, conversation_id=conversation_id, user=user, session=db_session_with_containers
|
||||
)
|
||||
|
||||
# Assert - Verify two-step deletion process
|
||||
# Step 1: Immediate logical deletion
|
||||
deleted = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation_id))
|
||||
assert deleted is not None
|
||||
assert deleted.is_deleted is True
|
||||
|
||||
# Step 2: Async cleanup task triggered
|
||||
# The Celery task will handle cleanup of messages, annotations, etc.
|
||||
mock_delete_task.delay.assert_called_once_with(conversation_id)
|
||||
|
||||
@patch("services.conversation_service.delete_conversation_related_data")
|
||||
def test_delete_conversation_not_owned_by_account(
|
||||
self,
|
||||
mock_delete_task,
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
"""
|
||||
Test deletion is denied when conversation belongs to a different account.
|
||||
"""
|
||||
# Arrange
|
||||
app_model, owner_account = ConversationServiceIntegrationTestDataFactory.create_app_and_account(
|
||||
db_session_with_containers
|
||||
)
|
||||
_, other_account = ConversationServiceIntegrationTestDataFactory.create_app_and_account(
|
||||
db_session_with_containers
|
||||
)
|
||||
conversation = ConversationServiceIntegrationTestDataFactory.create_conversation(
|
||||
db_session_with_containers,
|
||||
app_model,
|
||||
owner_account,
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ConversationNotExistsError):
|
||||
ConversationService.delete(
|
||||
app_model=app_model,
|
||||
conversation_id=conversation.id,
|
||||
user=other_account,
|
||||
session=db_session_with_containers,
|
||||
)
|
||||
|
||||
# Verify no deletion and no async cleanup trigger
|
||||
not_deleted = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation.id))
|
||||
assert not_deleted is not None
|
||||
mock_delete_task.delay.assert_not_called()
|
||||
|
||||
@patch("services.conversation_service.delete_conversation_related_data")
|
||||
def test_delete_handles_exception_and_rollback(
|
||||
self,
|
||||
mock_delete_task,
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
"""
|
||||
Test that delete propagates exceptions and does not trigger the cleanup task.
|
||||
|
||||
When a DB error occurs during deletion, the conversation row stays in place.
|
||||
"""
|
||||
# Arrange
|
||||
app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account(
|
||||
db_session_with_containers
|
||||
)
|
||||
conversation = ConversationServiceIntegrationTestDataFactory.create_conversation(
|
||||
db_session_with_containers, app_model, user
|
||||
)
|
||||
conversation_id = conversation.id
|
||||
|
||||
# Act — force an error during the soft-delete commit to exercise rollback
|
||||
with patch.object(db_session_with_containers, "commit", side_effect=Exception("DB error")):
|
||||
with pytest.raises(Exception, match="DB error"):
|
||||
ConversationService.delete(
|
||||
app_model=app_model,
|
||||
conversation_id=conversation_id,
|
||||
user=user,
|
||||
session=db_session_with_containers,
|
||||
)
|
||||
|
||||
# Assert — related-data deletion is not scheduled.
|
||||
mock_delete_task.delay.assert_not_called()
|
||||
|
||||
# Conversation is still present because the deletion was never committed
|
||||
still_there = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation_id))
|
||||
assert still_there is not None
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Console conversation deletion through real account admission and SQLite."""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from http import HTTPStatus
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Connection, Engine, event, select, 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.common.rbac import RBAC_CHECKS_ATTR, PlainApp, RBACCheck, RBACPermission
|
||||
from controllers.console.app.conversation import ChatConversationDetailApi, CompletionConversationDetailApi
|
||||
from enums import DeploymentEdition
|
||||
from enums.account import TenantAccountRole
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_login import DifyLoginManager, unauthorized_handler
|
||||
from libs.external_api import ExternalApi
|
||||
from libs.token import generate_csrf_token
|
||||
from models import Account, Tenant, TenantAccountJoin
|
||||
from models.agent import AgentWorkingResourceStatus, AgentWorkspace, AgentWorkspaceOwnerType
|
||||
from models.enums import ConversationFromSource
|
||||
from models.model import App, AppMode, Conversation
|
||||
from repositories.conversation_repository import ConversationRepository
|
||||
from services.conversation_command_service import ConversationCommandService
|
||||
from tests.helpers.unit.model_factories import make_account, make_app, make_conversation
|
||||
|
||||
type _Kind = Literal["chat", "completion"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Services:
|
||||
conversation_commands: ConversationCommandService
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Harness:
|
||||
app: Flask
|
||||
factory: sessionmaker[Session]
|
||||
account: Account
|
||||
tenant: Tenant
|
||||
target: App
|
||||
conversation: Conversation
|
||||
sessions: list[Session] = field(default_factory=list)
|
||||
cleanups: list[tuple[str, str, tuple[str, ...]]] = field(default_factory=list)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
kind: _Kind,
|
||||
*,
|
||||
app_id: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
authenticated: bool = True,
|
||||
csrf: bool = True,
|
||||
) -> TestResponse:
|
||||
client = self.app.test_client()
|
||||
headers: dict[str, str] = {}
|
||||
if authenticated:
|
||||
with client.session_transaction() as session:
|
||||
session["_user_id"] = self.account.id
|
||||
session["_fresh"] = True
|
||||
if csrf:
|
||||
token = generate_csrf_token(self.account.id)
|
||||
client.set_cookie(COOKIE_NAME_CSRF_TOKEN, token)
|
||||
headers[HEADER_NAME_CSRF_TOKEN] = token
|
||||
response = client.delete(
|
||||
f"/apps/{app_id or self.target.id}/{kind}-conversations/{conversation_id or self.conversation.id}",
|
||||
headers=headers,
|
||||
)
|
||||
self.assert_closed()
|
||||
assert response.content_type == "application/json"
|
||||
return response
|
||||
|
||||
def assert_closed(self) -> None:
|
||||
assert all(not session.in_transaction() and not session.identity_map for session in self.sessions)
|
||||
|
||||
def mode(self, mode: AppMode) -> None:
|
||||
with self.factory.begin() as session:
|
||||
session.execute(update(App).where(App.id == self.target.id).values(mode=mode))
|
||||
self.target.mode = mode
|
||||
|
||||
def assert_unchanged(self, *, previously_deleted: bool = False) -> None:
|
||||
with self.factory() as session:
|
||||
stored = session.get(Conversation, self.conversation.id)
|
||||
assert stored is not None
|
||||
assert stored.is_deleted is previously_deleted
|
||||
assert stored.read_at is None
|
||||
assert stored.read_account_id is None
|
||||
assert not self.cleanups
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(
|
||||
sqlite_engine: Engine, sqlite_session_factory: sessionmaker[Session], config_overrides: Callable[..., None]
|
||||
) -> Iterator[_Harness]:
|
||||
config_overrides(
|
||||
DEPLOYMENT_EDITION=DeploymentEdition.CLOUD,
|
||||
LOGIN_DISABLED=False,
|
||||
RBAC_ENABLED=False,
|
||||
SECRET_KEY="conversation-http-tests-key-at-least-32-bytes",
|
||||
CONSOLE_WEB_URL="http://localhost",
|
||||
CONSOLE_API_URL="http://localhost",
|
||||
COOKIE_DOMAIN="",
|
||||
)
|
||||
tenant = Tenant(name="Console workspace")
|
||||
account = make_account(account_id=str(uuid4()), timezone="UTC")
|
||||
target = make_app(app_id=str(uuid4()), tenant_id=tenant.id)
|
||||
conversation = make_conversation(
|
||||
conversation_id=str(uuid4()),
|
||||
app_id=target.id,
|
||||
inputs={},
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account.id,
|
||||
is_deleted=False,
|
||||
)
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add_all(
|
||||
[
|
||||
tenant,
|
||||
account,
|
||||
target,
|
||||
conversation,
|
||||
TenantAccountJoin(tenant_id=tenant.id, account_id=account.id, role=TenantAccountRole.OWNER),
|
||||
]
|
||||
)
|
||||
app = Flask(__name__)
|
||||
app.config.update(
|
||||
TESTING=True,
|
||||
RESTX_ERROR_404_HELP=False,
|
||||
SECRET_KEY="console-test-session",
|
||||
SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url),
|
||||
)
|
||||
db.init_app(app)
|
||||
login_manager = DifyLoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.unauthorized_handler(unauthorized_handler)
|
||||
state = _Harness(app, sqlite_session_factory, account, tenant, target, conversation)
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_account(account_id: str) -> Account | None:
|
||||
with sqlite_session_factory() as session:
|
||||
loaded = session.get(Account, account_id)
|
||||
if loaded is not None:
|
||||
loaded.set_tenant_id_with_session(tenant.id, session=session)
|
||||
return loaded
|
||||
|
||||
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
|
||||
@event.listens_for(factory, "after_begin")
|
||||
def record_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
state.assert_closed()
|
||||
state.sessions.append(session)
|
||||
|
||||
def enqueue_cleanup(*, tenant_id: str, conversation_id: str, retired_workspace_ids: tuple[str, ...]) -> None:
|
||||
# Cleanup crosses an external queue boundary only after the real command
|
||||
# transaction has closed and its committed deletion is visible.
|
||||
state.assert_closed()
|
||||
with sqlite_session_factory() as session:
|
||||
stored = session.get(Conversation, conversation_id)
|
||||
assert stored is not None
|
||||
assert stored.is_deleted
|
||||
for workspace_id in retired_workspace_ids:
|
||||
workspace = session.get(AgentWorkspace, workspace_id)
|
||||
assert workspace is not None
|
||||
assert workspace.status == AgentWorkingResourceStatus.RETIRED
|
||||
assert workspace.active_guard is None
|
||||
assert workspace.retired_at is not None
|
||||
state.cleanups.append((tenant_id, conversation_id, retired_workspace_ids))
|
||||
|
||||
app.extensions["application_services"] = _Services(
|
||||
ConversationCommandService(
|
||||
conversations=ConversationRepository(session_factory=factory), enqueue_delete_cleanup=enqueue_cleanup
|
||||
)
|
||||
)
|
||||
api = ExternalApi(app)
|
||||
api.add_resource(ChatConversationDetailApi, "/apps/<uuid:app_id>/chat-conversations/<uuid:conversation_id>")
|
||||
api.add_resource(
|
||||
CompletionConversationDetailApi, "/apps/<uuid:app_id>/completion-conversations/<uuid:conversation_id>"
|
||||
)
|
||||
yield state
|
||||
with app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "mode"),
|
||||
[
|
||||
("completion", AppMode.COMPLETION),
|
||||
("chat", AppMode.CHAT),
|
||||
("chat", AppMode.AGENT_CHAT),
|
||||
("chat", AppMode.ADVANCED_CHAT),
|
||||
("chat", AppMode.AGENT),
|
||||
],
|
||||
)
|
||||
def test_delete_commits_retirement_before_cleanup_and_returns_empty_204(
|
||||
harness: _Harness, kind: _Kind, mode: AppMode
|
||||
) -> None:
|
||||
harness.mode(mode)
|
||||
with harness.factory.begin() as session:
|
||||
workspace = AgentWorkspace(
|
||||
id=str(uuid4()),
|
||||
tenant_id=harness.target.tenant_id,
|
||||
app_id=harness.target.id,
|
||||
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
|
||||
owner_id=harness.conversation.id,
|
||||
owner_scope_key="chatflow-node",
|
||||
backend_workspace_ref="backend-workspace",
|
||||
status=AgentWorkingResourceStatus.ACTIVE,
|
||||
active_guard=1,
|
||||
)
|
||||
session.add(workspace)
|
||||
|
||||
response = harness.delete(kind)
|
||||
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
assert response.data == b""
|
||||
assert "Content-Length" not in response.headers
|
||||
assert harness.cleanups == [(harness.tenant.id, harness.conversation.id, (workspace.id,))]
|
||||
assert harness.sessions
|
||||
with harness.factory() as session:
|
||||
stored = session.get(Conversation, harness.conversation.id)
|
||||
assert stored is not None
|
||||
assert stored.read_at is None
|
||||
assert stored.read_account_id is None
|
||||
|
||||
repeated = harness.delete(kind)
|
||||
assert repeated.status_code == HTTPStatus.NOT_FOUND
|
||||
assert repeated.get_json()["code"] == "conversation_not_found"
|
||||
assert len(harness.cleanups) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
@pytest.mark.parametrize("field", ["app_id", "from_source", "from_account_id", "from_end_user_id", "is_deleted"])
|
||||
def test_delete_cannot_target_another_conversation_owner(harness: _Harness, kind: _Kind, field: str) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
value: str | bool
|
||||
if field == "is_deleted":
|
||||
value = True
|
||||
elif field == "from_source":
|
||||
value = ConversationFromSource.API
|
||||
elif field == "from_end_user_id":
|
||||
# Account and EndUser IDs remain different owner namespaces even if equal.
|
||||
value = harness.account.id
|
||||
else:
|
||||
value = str(uuid4())
|
||||
with harness.factory.begin() as session:
|
||||
session.execute(update(Conversation).where(Conversation.id == harness.conversation.id).values({field: value}))
|
||||
response = harness.delete(kind)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["code"] == "conversation_not_found"
|
||||
harness.assert_unchanged(previously_deleted=field == "is_deleted")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
def test_missing_conversation_does_not_delete_existing_conversation(harness: _Harness, kind: _Kind) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
response = harness.delete(kind, conversation_id=str(uuid4()))
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["code"] == "conversation_not_found"
|
||||
harness.assert_unchanged()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
@pytest.mark.parametrize("unavailable", ["missing", "other_tenant"])
|
||||
def test_app_scope_is_checked_before_delete(harness: _Harness, kind: _Kind, unavailable: str) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
with harness.factory.begin() as session:
|
||||
target = session.get(App, harness.target.id)
|
||||
assert target is not None
|
||||
if unavailable == "missing":
|
||||
session.delete(target)
|
||||
else:
|
||||
target.tenant_id = str(uuid4())
|
||||
response = harness.delete(kind)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["code"] == "app_not_found"
|
||||
harness.assert_unchanged()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "mode"),
|
||||
[("chat", AppMode.COMPLETION), ("chat", AppMode.WORKFLOW), ("completion", AppMode.CHAT)],
|
||||
)
|
||||
def test_unsupported_app_mode_cannot_delete_conversation(harness: _Harness, kind: _Kind, mode: AppMode) -> None:
|
||||
harness.mode(mode)
|
||||
response = harness.delete(kind)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["code"] == "app_not_found"
|
||||
harness.assert_unchanged()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[
|
||||
TenantAccountRole.OWNER,
|
||||
TenantAccountRole.ADMIN,
|
||||
TenantAccountRole.EDITOR,
|
||||
TenantAccountRole.NORMAL,
|
||||
TenantAccountRole.DATASET_OPERATOR,
|
||||
],
|
||||
)
|
||||
def test_existing_edit_roles_are_preserved(harness: _Harness, kind: _Kind, role: TenantAccountRole) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
with harness.factory.begin() as session:
|
||||
membership = session.scalar(select(TenantAccountJoin).where(TenantAccountJoin.account_id == harness.account.id))
|
||||
assert membership is not None
|
||||
membership.role = role
|
||||
response = harness.delete(kind)
|
||||
if TenantAccountRole.is_editing_role(role):
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
assert len(harness.cleanups) == 1
|
||||
else:
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
harness.assert_unchanged()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
def test_login_and_csrf_remain_required(harness: _Harness, kind: _Kind) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
assert harness.delete(kind, authenticated=False).status_code == HTTPStatus.UNAUTHORIZED
|
||||
assert harness.delete(kind, csrf=False).status_code == HTTPStatus.UNAUTHORIZED
|
||||
harness.assert_unchanged()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("handler", [ChatConversationDetailApi.delete, CompletionConversationDetailApi.delete])
|
||||
def test_app_edit_rbac_check_is_declared(handler: Callable[..., object]) -> None:
|
||||
checks: tuple[RBACCheck, ...] = vars(handler)[RBAC_CHECKS_ATTR]
|
||||
assert len(checks) == 1
|
||||
assert checks[0].scene == RBACPermission.APP_EDIT
|
||||
assert isinstance(checks[0].locator, PlainApp)
|
||||
assert checks[0].locator.param == "app_id"
|
||||
@@ -0,0 +1,477 @@
|
||||
"""Console detail reads use real admission, queries, rendering and read receipts."""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from http import HTTPStatus
|
||||
from typing import Literal, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Connection, event, select, text, 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.common.rbac import RBAC_CHECKS_ATTR, PlainApp, RBACCheck, RBACPermission
|
||||
from controllers.console.app.conversation import ChatConversationDetailApi, CompletionConversationDetailApi
|
||||
from enums.account import TenantAccountRole
|
||||
from fields.conversation_fields import ConversationDetail, ConversationMessageDetail, ConversationResponseSource
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from libs.token import generate_csrf_token
|
||||
from models import TenantAccountJoin
|
||||
from models.agent import Agent, AgentScope, AgentSource, AgentStatus
|
||||
from models.enums import ConversationFromSource, CreatorUserRole, FeedbackFromSource, FeedbackRating
|
||||
from models.model import (
|
||||
App,
|
||||
AppAnnotationHitHistory,
|
||||
AppMode,
|
||||
Conversation,
|
||||
Message,
|
||||
MessageAgentThought,
|
||||
MessageAnnotation,
|
||||
MessageFeedback,
|
||||
)
|
||||
from models.utils.file_input_compat import restore_input_files
|
||||
from repositories.conversation_repository import ConversationRepository
|
||||
from services.conversation_command_service import ConversationCommandService
|
||||
from services.conversation_query_service import ConversationQueryService
|
||||
from services.message_query_adapters import MessageFileResolver
|
||||
from tests.e2e_tests.contracts.controllers.console.app import test_conversation_delete as delete_tests
|
||||
from tests.e2e_tests.contracts.controllers.console.app.test_conversation_delete import _Harness as DeleteHarness
|
||||
from tests.helpers.unit.model_factories import make_account, make_message
|
||||
|
||||
type _Kind = Literal["chat", "completion"]
|
||||
_UPDATED_AT = datetime(2024, 2, 3, 4, 5, 6)
|
||||
delete_harness = delete_tests.harness
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Services:
|
||||
conversation_commands: ConversationCommandService
|
||||
conversation_queries: ConversationQueryService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Harness:
|
||||
source: DeleteHarness
|
||||
|
||||
def get(
|
||||
self,
|
||||
kind: _Kind,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
authenticated: bool = True,
|
||||
csrf: bool = True,
|
||||
) -> TestResponse:
|
||||
source = self.source
|
||||
client = source.app.test_client()
|
||||
actor = account_id or source.account.id
|
||||
headers: dict[str, str] = {}
|
||||
if authenticated:
|
||||
with client.session_transaction() as session:
|
||||
session["_user_id"] = actor
|
||||
session["_fresh"] = True
|
||||
if csrf:
|
||||
token = generate_csrf_token(actor)
|
||||
client.set_cookie(COOKIE_NAME_CSRF_TOKEN, token)
|
||||
headers[HEADER_NAME_CSRF_TOKEN] = token
|
||||
response = client.get(
|
||||
f"/apps/{source.target.id}/{kind}-conversations/{conversation_id or source.conversation.id}",
|
||||
headers=headers,
|
||||
)
|
||||
source.assert_closed()
|
||||
assert response.content_type == "application/json"
|
||||
assert int(response.headers["Content-Length"]) == len(response.data)
|
||||
assert not source.cleanups
|
||||
return response
|
||||
|
||||
def assert_unread(self) -> None:
|
||||
with self.source.factory() as session:
|
||||
conversation = session.get_one(Conversation, self.source.conversation.id)
|
||||
assert conversation.read_at is None
|
||||
assert conversation.read_account_id is None
|
||||
assert conversation.updated_at == _UPDATED_AT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(delete_harness: DeleteHarness) -> _Harness:
|
||||
previous = cast(delete_tests._Services, delete_harness.app.extensions["application_services"])
|
||||
factory = sessionmaker(bind=delete_harness.factory.kw["bind"], expire_on_commit=False)
|
||||
|
||||
@event.listens_for(factory, "after_begin")
|
||||
def record_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
delete_harness.assert_closed()
|
||||
delete_harness.sessions.append(session)
|
||||
|
||||
delete_harness.app.extensions["application_services"] = _Services(
|
||||
conversation_commands=previous.conversation_commands,
|
||||
conversation_queries=ConversationQueryService(
|
||||
conversations=ConversationRepository(session_factory=factory),
|
||||
restore_inputs=restore_input_files,
|
||||
files=MessageFileResolver(),
|
||||
),
|
||||
)
|
||||
with delete_harness.factory.begin() as session:
|
||||
session.execute(
|
||||
update(Conversation)
|
||||
.where(Conversation.id == delete_harness.conversation.id)
|
||||
.values({Conversation.updated_at: _UPDATED_AT, Conversation.introduction: "Welcome"})
|
||||
)
|
||||
return _Harness(delete_harness)
|
||||
|
||||
|
||||
def _message(harness: _Harness, *, created_at: datetime = _UPDATED_AT) -> Message:
|
||||
source = harness.source
|
||||
message = make_message(
|
||||
message_id=str(uuid4()),
|
||||
app_id=source.target.id,
|
||||
conversation_id=source.conversation.id,
|
||||
inputs={"empty": "", "zero": 0, "false": False, "null": None, "list": []},
|
||||
query="The first question",
|
||||
message=[{"role": "user", "text": "The prompt"}],
|
||||
answer="The answer",
|
||||
message_tokens=7,
|
||||
answer_tokens=13,
|
||||
provider_response_latency=1.25,
|
||||
message_unit_price=Decimal(0),
|
||||
answer_unit_price=Decimal(0),
|
||||
total_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
from_account_id=source.account.id,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
)
|
||||
message.message_metadata = '{"usage":{"total_tokens":20}}'
|
||||
with source.factory.begin() as session:
|
||||
session.add(message)
|
||||
return message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "mode"),
|
||||
[
|
||||
("completion", AppMode.COMPLETION),
|
||||
("chat", AppMode.CHAT),
|
||||
("chat", AppMode.AGENT_CHAT),
|
||||
("chat", AppMode.ADVANCED_CHAT),
|
||||
("chat", AppMode.AGENT),
|
||||
],
|
||||
)
|
||||
def test_empty_details_keep_response_and_mark_first_read_without_touching_updated_at(
|
||||
harness: _Harness, kind: _Kind, mode: AppMode
|
||||
) -> None:
|
||||
source = harness.source
|
||||
source.mode(mode)
|
||||
with source.factory() as session:
|
||||
stored = session.get_one(Conversation, source.conversation.id)
|
||||
schema = ConversationMessageDetail if kind == "completion" else ConversationDetail
|
||||
expected = dump_response(schema, ConversationResponseSource(stored, session=session))
|
||||
before = naive_utc_now()
|
||||
response = harness.get(kind)
|
||||
after = naive_utc_now()
|
||||
assert response.status_code == HTTPStatus.OK, response.get_json()
|
||||
assert response.get_json() == expected
|
||||
assert "read_at" not in response.get_json()
|
||||
if kind == "completion":
|
||||
assert response.get_json()["message"] is None
|
||||
else:
|
||||
assert response.get_json()["message_count"] == 0
|
||||
assert response.get_json()["updated_at"] == to_timestamp(_UPDATED_AT)
|
||||
with source.factory() as session:
|
||||
stored = session.get_one(Conversation, source.conversation.id)
|
||||
assert stored.read_at is not None
|
||||
assert before <= stored.read_at <= after
|
||||
assert stored.read_account_id == source.account.id
|
||||
assert stored.updated_at == _UPDATED_AT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
@pytest.mark.parametrize("scope", ["other_account", "end_user", "soft_deleted"])
|
||||
def test_management_detail_preserves_app_wide_visibility(harness: _Harness, kind: _Kind, scope: str) -> None:
|
||||
source = harness.source
|
||||
source.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
with source.factory.begin() as session:
|
||||
conversation = session.get_one(Conversation, source.conversation.id)
|
||||
if scope == "other_account":
|
||||
conversation.from_account_id = str(uuid4())
|
||||
elif scope == "end_user":
|
||||
conversation.from_source = ConversationFromSource.API
|
||||
conversation.from_account_id = None
|
||||
conversation.from_end_user_id = str(uuid4())
|
||||
else:
|
||||
conversation.is_deleted = True
|
||||
conversation.updated_at = _UPDATED_AT
|
||||
response = harness.get(kind)
|
||||
assert response.status_code == HTTPStatus.OK, response.get_json()
|
||||
assert response.get_json()["id"] == source.conversation.id
|
||||
with source.factory() as session:
|
||||
stored = session.get_one(Conversation, source.conversation.id)
|
||||
assert stored.is_deleted is (scope == "soft_deleted")
|
||||
assert stored.read_account_id == source.account.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
def test_second_reader_does_not_replace_first_read_receipt(harness: _Harness, kind: _Kind) -> None:
|
||||
source = harness.source
|
||||
source.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
assert harness.get(kind).status_code == HTTPStatus.OK
|
||||
with source.factory.begin() as session:
|
||||
stored = session.get_one(Conversation, source.conversation.id)
|
||||
first_read_at = stored.read_at
|
||||
second = make_account(account_id=str(uuid4()), name="Second reader")
|
||||
session.add_all(
|
||||
[
|
||||
second,
|
||||
TenantAccountJoin(tenant_id=source.tenant.id, account_id=second.id, role=TenantAccountRole.EDITOR),
|
||||
]
|
||||
)
|
||||
assert harness.get(kind, account_id=second.id).status_code == HTTPStatus.OK
|
||||
with source.factory() as session:
|
||||
stored = session.get_one(Conversation, source.conversation.id)
|
||||
assert stored.read_at == first_read_at
|
||||
assert stored.read_account_id == source.account.id
|
||||
assert stored.updated_at == _UPDATED_AT
|
||||
|
||||
|
||||
def test_completion_detail_preserves_nested_message_projection(harness: _Harness) -> None:
|
||||
source = harness.source
|
||||
source.mode(AppMode.COMPLETION)
|
||||
first = _message(harness)
|
||||
_message(harness, created_at=_UPDATED_AT + timedelta(seconds=1))
|
||||
with source.factory.begin() as session:
|
||||
stored = session.get_one(Conversation, source.conversation.id)
|
||||
stored.override_model_configs = json.dumps({"model": {"name": "fixture-model"}, "pre_prompt": "Be useful"})
|
||||
session.get_one(Message, first.id).message_metadata = json.dumps(
|
||||
{"usage": {"total_tokens": 20}, "retriever_resources": []}
|
||||
)
|
||||
stored.updated_at = _UPDATED_AT
|
||||
annotation = MessageAnnotation(
|
||||
app_id=source.target.id,
|
||||
conversation_id=source.conversation.id,
|
||||
message_id=first.id,
|
||||
account_id=source.account.id,
|
||||
question="Question",
|
||||
content="Annotated answer",
|
||||
)
|
||||
session.add_all(
|
||||
[
|
||||
annotation,
|
||||
MessageFeedback(
|
||||
app_id=source.target.id,
|
||||
conversation_id=source.conversation.id,
|
||||
message_id=first.id,
|
||||
rating=FeedbackRating.LIKE,
|
||||
from_source=FeedbackFromSource.USER,
|
||||
from_account_id=source.account.id,
|
||||
content="Helpful",
|
||||
),
|
||||
AppAnnotationHitHistory(
|
||||
app_id=source.target.id,
|
||||
annotation_id=annotation.id,
|
||||
source="annotation",
|
||||
question="Question",
|
||||
account_id=source.account.id,
|
||||
score=0.8,
|
||||
message_id=first.id,
|
||||
annotation_question="Question",
|
||||
annotation_content="Annotated answer",
|
||||
),
|
||||
MessageAgentThought(
|
||||
message_id=first.id,
|
||||
position=2,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=source.account.id,
|
||||
thought="Second thought",
|
||||
tool_labels_str="{}",
|
||||
),
|
||||
MessageAgentThought(
|
||||
message_id=first.id,
|
||||
position=1,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=source.account.id,
|
||||
thought="First thought",
|
||||
tool="search",
|
||||
tool_labels_str='{"search":"Search"}',
|
||||
message_files='["file-1"]',
|
||||
message_chain_id=str(uuid4()),
|
||||
),
|
||||
]
|
||||
)
|
||||
with source.app.app_context(), source.factory() as session:
|
||||
stored = session.get_one(Conversation, source.conversation.id)
|
||||
expected = dump_response(ConversationMessageDetail, ConversationResponseSource(stored, session=session))
|
||||
response = harness.get("completion")
|
||||
assert response.status_code == HTTPStatus.OK, response.get_json()
|
||||
actual = response.get_json()
|
||||
assert actual == expected
|
||||
message = actual["message"]
|
||||
assert message["id"] == first.id
|
||||
assert message["inputs"] == {"empty": "", "zero": 0, "false": False, "null": None, "list": []}
|
||||
assert message["metadata"] == {"usage": {"total_tokens": 20}, "retriever_resources": []}
|
||||
assert message["annotation_hit_history"]["annotation_id"] == annotation.id
|
||||
assert message["feedbacks"][0]["from_account"]["id"] == source.account.id
|
||||
assert [thought["thought"] for thought in message["agent_thoughts"]] == ["First thought", "Second thought"]
|
||||
assert message["agent_thoughts"][0]["chain_id"] is not None
|
||||
assert message["message_files"] == []
|
||||
|
||||
|
||||
def test_chat_detail_preserves_annotations_and_feedback_counts(harness: _Harness) -> None:
|
||||
source = harness.source
|
||||
first = _message(harness)
|
||||
_message(harness, created_at=_UPDATED_AT + timedelta(seconds=1))
|
||||
with source.factory.begin() as session:
|
||||
session.add_all(
|
||||
[
|
||||
MessageAnnotation(
|
||||
app_id=source.target.id,
|
||||
conversation_id=source.conversation.id,
|
||||
message_id=first.id,
|
||||
account_id=source.account.id,
|
||||
question="Question",
|
||||
content="Annotated answer",
|
||||
),
|
||||
*[
|
||||
MessageFeedback(
|
||||
app_id=source.target.id,
|
||||
conversation_id=source.conversation.id,
|
||||
message_id=first.id,
|
||||
rating=rating,
|
||||
from_source=feedback_source,
|
||||
)
|
||||
for feedback_source, rating in [
|
||||
(FeedbackFromSource.USER, FeedbackRating.LIKE),
|
||||
(FeedbackFromSource.USER, FeedbackRating.DISLIKE),
|
||||
(FeedbackFromSource.ADMIN, FeedbackRating.LIKE),
|
||||
]
|
||||
],
|
||||
]
|
||||
)
|
||||
with source.factory() as session:
|
||||
stored = session.get_one(Conversation, source.conversation.id)
|
||||
expected = dump_response(ConversationDetail, ConversationResponseSource(stored, session=session))
|
||||
response = harness.get("chat")
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.get_json() == expected
|
||||
assert response.get_json()["annotated"] is True
|
||||
assert response.get_json()["message_count"] == 2
|
||||
assert response.get_json()["user_feedback_stats"] == {"like": 1, "dislike": 1}
|
||||
assert response.get_json()["admin_feedback_stats"] == {"like": 1, "dislike": 0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
@pytest.mark.parametrize("gate", ["missing", "tenant", "status", "hidden_agent", "mode"])
|
||||
def test_unavailable_app_cannot_be_read_or_marked(harness: _Harness, kind: _Kind, gate: str) -> None:
|
||||
source = harness.source
|
||||
source.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
with source.factory.begin() as session:
|
||||
app = session.get_one(App, source.target.id)
|
||||
if gate == "missing":
|
||||
session.delete(app)
|
||||
elif gate == "tenant":
|
||||
app.tenant_id = str(uuid4())
|
||||
elif gate == "status":
|
||||
session.execute(text("UPDATE apps SET status = 'disabled' WHERE id = :id"), {"id": app.id})
|
||||
elif gate == "mode":
|
||||
app.mode = AppMode.WORKFLOW
|
||||
else:
|
||||
app.mode = AppMode.AGENT
|
||||
session.add(
|
||||
Agent(
|
||||
tenant_id=app.tenant_id,
|
||||
name="Hidden",
|
||||
scope=AgentScope.WORKFLOW_ONLY,
|
||||
source=AgentSource.WORKFLOW,
|
||||
backing_app_id=app.id,
|
||||
status=AgentStatus.ARCHIVED,
|
||||
)
|
||||
)
|
||||
response = harness.get(kind)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["code"] == "app_not_found"
|
||||
harness.assert_unread()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
@pytest.mark.parametrize("change", ["missing", "other_app"])
|
||||
def test_missing_or_other_app_conversation_does_not_mark_existing_row(
|
||||
harness: _Harness, kind: _Kind, change: str
|
||||
) -> None:
|
||||
source = harness.source
|
||||
source.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
conversation_id = str(uuid4()) if change == "missing" else source.conversation.id
|
||||
if change == "other_app":
|
||||
with source.factory.begin() as session:
|
||||
session.execute(
|
||||
update(Conversation)
|
||||
.where(Conversation.id == conversation_id)
|
||||
.values({Conversation.app_id: str(uuid4()), Conversation.updated_at: _UPDATED_AT})
|
||||
)
|
||||
response = harness.get(kind, conversation_id=conversation_id)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.get_json()["code"] == "conversation_not_found"
|
||||
harness.assert_unread()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
@pytest.mark.parametrize("role", list(TenantAccountRole))
|
||||
def test_detail_preserves_edit_roles(harness: _Harness, kind: _Kind, role: TenantAccountRole) -> None:
|
||||
source = harness.source
|
||||
source.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
with source.factory.begin() as session:
|
||||
membership = session.scalar(select(TenantAccountJoin).where(TenantAccountJoin.account_id == source.account.id))
|
||||
assert membership is not None
|
||||
membership.role = role
|
||||
response = harness.get(kind)
|
||||
if TenantAccountRole.is_editing_role(role):
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
else:
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
harness.assert_unread()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
def test_detail_requires_login_and_csrf(harness: _Harness, kind: _Kind) -> None:
|
||||
assert harness.get(kind, authenticated=False).status_code == HTTPStatus.UNAUTHORIZED
|
||||
assert harness.get(kind, csrf=False).status_code == HTTPStatus.UNAUTHORIZED
|
||||
harness.assert_unread()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("handler", [ChatConversationDetailApi.get, CompletionConversationDetailApi.get])
|
||||
def test_detail_declares_app_view_layout(handler: Callable[..., object]) -> None:
|
||||
checks: tuple[RBACCheck, ...] = vars(handler)[RBAC_CHECKS_ATTR]
|
||||
assert len(checks) == 1
|
||||
assert checks[0].scene == RBACPermission.APP_VIEW_LAYOUT
|
||||
assert isinstance(checks[0].locator, PlainApp)
|
||||
assert checks[0].locator.param == "app_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["metadata", "thought_files"])
|
||||
def test_failed_message_rendering_does_not_mark_conversation_read(harness: _Harness, failure: str) -> None:
|
||||
source = harness.source
|
||||
source.mode(AppMode.COMPLETION)
|
||||
message = _message(harness)
|
||||
with source.factory.begin() as session:
|
||||
if failure == "metadata":
|
||||
stored = session.get_one(Message, message.id)
|
||||
stored.message_metadata = "invalid-json"
|
||||
else:
|
||||
session.add(
|
||||
MessageAgentThought(
|
||||
message_id=message.id,
|
||||
position=1,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=source.account.id,
|
||||
message_files='[{"not":"a file id"}]',
|
||||
tool_labels_str="{}",
|
||||
)
|
||||
)
|
||||
response = harness.get("completion")
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.get_json()["code"] == "invalid_param"
|
||||
harness.assert_unread()
|
||||
@@ -0,0 +1,457 @@
|
||||
"""Console conversation lists through real account admission, services and SQLite."""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Connection, Engine, event, select
|
||||
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.common.rbac import RBAC_CHECKS_ATTR, PlainApp, RBACCheck, RBACPermission
|
||||
from controllers.console.app.conversation import ChatConversationApi, CompletionConversationApi
|
||||
from enums import DeploymentEdition
|
||||
from enums.account import TenantAccountRole
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_login import DifyLoginManager, unauthorized_handler
|
||||
from fields.conversation_fields import (
|
||||
ConversationPagination,
|
||||
ConversationResponseSource,
|
||||
ConversationWithSummaryPagination,
|
||||
)
|
||||
from libs.external_api import ExternalApi
|
||||
from libs.helper import dump_response
|
||||
from libs.token import generate_csrf_token
|
||||
from models import Account, Tenant, TenantAccountJoin
|
||||
from models.enums import ConversationFromSource, FeedbackFromSource, FeedbackRating
|
||||
from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
|
||||
from models.utils.file_input_compat import restore_input_files
|
||||
from repositories.conversation_repository import ConversationRepository
|
||||
from services.conversation_query_service import ConversationQueryService
|
||||
from services.message_query_adapters import MessageFileResolver
|
||||
from tests.helpers.unit.model_factories import make_account, make_app, make_conversation, make_message
|
||||
|
||||
type _Kind = Literal["chat", "completion"]
|
||||
_CREATED_AT = datetime(2024, 1, 15, 6, 30, 0)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Services:
|
||||
conversation_queries: ConversationQueryService
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Harness:
|
||||
app: Flask
|
||||
factory: sessionmaker[Session]
|
||||
account: Account
|
||||
tenant: Tenant
|
||||
target: App
|
||||
sessions: list[Session] = field(default_factory=list)
|
||||
after_account_load: Callable[[], None] | None = None
|
||||
|
||||
def get(
|
||||
self,
|
||||
kind: _Kind,
|
||||
*,
|
||||
query: dict[str, str | int] | None = None,
|
||||
app_id: str | None = None,
|
||||
authenticated: bool = True,
|
||||
csrf: bool = True,
|
||||
) -> TestResponse:
|
||||
client = self.app.test_client()
|
||||
headers: dict[str, str] = {}
|
||||
if authenticated:
|
||||
with client.session_transaction() as session:
|
||||
session["_user_id"] = self.account.id
|
||||
session["_fresh"] = True
|
||||
if csrf:
|
||||
token = generate_csrf_token(self.account.id)
|
||||
client.set_cookie(COOKIE_NAME_CSRF_TOKEN, token)
|
||||
headers[HEADER_NAME_CSRF_TOKEN] = token
|
||||
response = client.get(
|
||||
f"/apps/{app_id or self.target.id}/{kind}-conversations", query_string=query, headers=headers
|
||||
)
|
||||
assert all(not session.in_transaction() and not session.identity_map for session in self.sessions)
|
||||
assert response.content_type == "application/json"
|
||||
assert int(response.headers["Content-Length"]) == len(response.data)
|
||||
return response
|
||||
|
||||
def mode(self, mode: AppMode) -> None:
|
||||
with self.factory.begin() as session:
|
||||
target = session.get(App, self.target.id)
|
||||
assert target is not None
|
||||
target.mode = mode
|
||||
self.target.mode = mode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(
|
||||
sqlite_engine: Engine, sqlite_session_factory: sessionmaker[Session], config_overrides: Callable[..., None]
|
||||
) -> Iterator[_Harness]:
|
||||
config_overrides(
|
||||
DEPLOYMENT_EDITION=DeploymentEdition.CLOUD,
|
||||
LOGIN_DISABLED=False,
|
||||
RBAC_ENABLED=False,
|
||||
SECRET_KEY="conversation-http-tests-key-at-least-32-bytes",
|
||||
CONSOLE_WEB_URL="http://localhost",
|
||||
CONSOLE_API_URL="http://localhost",
|
||||
COOKIE_DOMAIN="",
|
||||
)
|
||||
tenant = Tenant(name="Console workspace")
|
||||
account = make_account(account_id=str(uuid4()), timezone="Asia/Shanghai")
|
||||
target = make_app(app_id=str(uuid4()), tenant_id=tenant.id)
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add_all(
|
||||
[
|
||||
tenant,
|
||||
account,
|
||||
target,
|
||||
TenantAccountJoin(tenant_id=tenant.id, account_id=account.id, role=TenantAccountRole.OWNER),
|
||||
]
|
||||
)
|
||||
app = Flask(__name__)
|
||||
app.config.update(
|
||||
TESTING=True,
|
||||
RESTX_ERROR_404_HELP=False,
|
||||
SECRET_KEY="console-test-session",
|
||||
SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url),
|
||||
)
|
||||
db.init_app(app)
|
||||
login_manager = DifyLoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.unauthorized_handler(unauthorized_handler)
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_account(account_id: str) -> Account | None:
|
||||
with sqlite_session_factory() as session:
|
||||
loaded = session.get(Account, account_id)
|
||||
if loaded is not None:
|
||||
loaded.set_tenant_id_with_session(tenant.id, session=session)
|
||||
if state.after_account_load is not None:
|
||||
state.after_account_load()
|
||||
return loaded
|
||||
|
||||
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
state = _Harness(app, sqlite_session_factory, account, tenant, target)
|
||||
|
||||
@event.listens_for(factory, "after_begin")
|
||||
def record_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
assert all(not previous.in_transaction() and not previous.identity_map for previous in state.sessions)
|
||||
state.sessions.append(session)
|
||||
|
||||
app.extensions["application_services"] = _Services(
|
||||
ConversationQueryService(
|
||||
files=MessageFileResolver(),
|
||||
conversations=ConversationRepository(session_factory=factory),
|
||||
restore_inputs=restore_input_files,
|
||||
)
|
||||
)
|
||||
api = ExternalApi(app)
|
||||
api.add_resource(ChatConversationApi, "/apps/<uuid:app_id>/chat-conversations")
|
||||
api.add_resource(CompletionConversationApi, "/apps/<uuid:app_id>/completion-conversations")
|
||||
yield state
|
||||
with app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
def _conversation(
|
||||
harness: _Harness,
|
||||
*,
|
||||
app_id: str | None = None,
|
||||
name: str = "Conversation name",
|
||||
query: str = "First question",
|
||||
answer: str = "First answer",
|
||||
created_at: datetime = _CREATED_AT,
|
||||
updated_at: datetime = _CREATED_AT,
|
||||
deleted: bool = False,
|
||||
invoke_from: str = "web-app",
|
||||
with_message: bool = True,
|
||||
) -> Conversation:
|
||||
conversation = make_conversation(
|
||||
conversation_id=str(uuid4()),
|
||||
app_id=app_id or harness.target.id,
|
||||
mode=harness.target.mode,
|
||||
name=name,
|
||||
inputs={},
|
||||
status="normal",
|
||||
from_source=ConversationFromSource.API,
|
||||
from_account_id=harness.account.id,
|
||||
introduction="Conversation introduction",
|
||||
invoke_from=invoke_from,
|
||||
is_deleted=deleted,
|
||||
override_model_configs='{"pre_prompt":"Be useful","model":{"name":"example"}}',
|
||||
)
|
||||
conversation.created_at = created_at
|
||||
conversation.updated_at = updated_at
|
||||
message = make_message(
|
||||
message_id=str(uuid4()),
|
||||
app_id=conversation.app_id,
|
||||
conversation_id=conversation.id,
|
||||
inputs={"text": "", "zero": 0, "boolean": False, "empty": [], "null": None},
|
||||
query=query,
|
||||
answer=answer,
|
||||
message=[{"text": "Prompt", "role": "user"}],
|
||||
message_unit_price=Decimal(0),
|
||||
answer_unit_price=Decimal(0),
|
||||
total_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
with harness.factory.begin() as session:
|
||||
session.add(conversation)
|
||||
if with_message:
|
||||
session.add(message)
|
||||
return conversation
|
||||
|
||||
|
||||
def _ids(response: TestResponse) -> list[str]:
|
||||
assert response.status_code == 200, response.get_json()
|
||||
return [row["id"] for row in response.get_json()["data"]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
def test_empty_page_retains_wire_contract(harness: _Harness, kind: _Kind) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
response = harness.get(kind)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
def test_response_matches_legacy_projection_without_marking_read(harness: _Harness, kind: _Kind) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
conversation = _conversation(harness)
|
||||
with harness.factory.begin() as session:
|
||||
message = session.scalar(select(Message).where(Message.conversation_id == conversation.id))
|
||||
assert message is not None
|
||||
session.add_all(
|
||||
[
|
||||
MessageAnnotation(
|
||||
app_id=harness.target.id,
|
||||
conversation_id=conversation.id,
|
||||
message_id=message.id,
|
||||
account_id=harness.account.id,
|
||||
question="Question",
|
||||
content="Annotation answer",
|
||||
),
|
||||
MessageFeedback(
|
||||
app_id=harness.target.id,
|
||||
conversation_id=conversation.id,
|
||||
message_id=message.id,
|
||||
rating=FeedbackRating.LIKE,
|
||||
from_source=FeedbackFromSource.USER,
|
||||
),
|
||||
]
|
||||
)
|
||||
schema = ConversationPagination if kind == "completion" else ConversationWithSummaryPagination
|
||||
with harness.app.app_context(), harness.factory() as session:
|
||||
stored = session.get(Conversation, conversation.id)
|
||||
assert stored is not None
|
||||
expected = dump_response(
|
||||
schema,
|
||||
{
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"total": 1,
|
||||
"has_next": False,
|
||||
"items": [ConversationResponseSource(stored, session=session)],
|
||||
},
|
||||
)
|
||||
response = harness.get(kind)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == expected
|
||||
assert response.get_json()["data"][0]["model_config"] == {"model": {"name": "example"}, "pre_prompt": "Be useful"}
|
||||
with harness.factory() as session:
|
||||
stored = session.get(Conversation, conversation.id)
|
||||
assert stored is not None
|
||||
assert stored.read_at is None
|
||||
assert stored.read_account_id is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
def test_pagination_and_app_deletion_isolation(harness: _Harness, kind: _Kind) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
first = _conversation(harness, created_at=_CREATED_AT, updated_at=_CREATED_AT)
|
||||
second = _conversation(
|
||||
harness, created_at=_CREATED_AT + timedelta(hours=1), updated_at=_CREATED_AT + timedelta(hours=1)
|
||||
)
|
||||
_conversation(harness, app_id=str(uuid4()))
|
||||
_conversation(harness, deleted=True)
|
||||
first_page = harness.get(kind, query={"limit": 1})
|
||||
assert _ids(first_page) == [second.id]
|
||||
assert first_page.get_json()["total"] == 2
|
||||
assert first_page.get_json()["has_more"] is True
|
||||
second_page = harness.get(kind, query={"limit": 1, "page": 2})
|
||||
assert _ids(second_page) == [first.id]
|
||||
assert second_page.get_json()["has_more"] is False
|
||||
assert _ids(harness.get(kind, query={"page": 3, "limit": 1})) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
@pytest.mark.parametrize(
|
||||
"query", [{"limit": 0}, {"limit": 101}, {"page": 0}, {"page": 100000}, {"annotation_status": "bad"}]
|
||||
)
|
||||
def test_invalid_query_returns_422(harness: _Harness, kind: _Kind, query: dict[str, str | int]) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
response = harness.get(kind, query=query)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
@pytest.mark.parametrize("query", [{"start": "bad"}, {"start": "2024-01-16 00:00", "end": "2024-01-15 00:00"}])
|
||||
def test_invalid_date_range_is_precise_400(harness: _Harness, kind: _Kind, query: dict[str, str | int]) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
response = harness.get(kind, query=query)
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["code"] == "invalid_param"
|
||||
assert response.get_json()["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "sort_by"), [("completion", "-created_at"), ("chat", "created_at"), ("chat", "-updated_at")]
|
||||
)
|
||||
def test_account_timezone_and_exact_end_second_are_inclusive(harness: _Harness, kind: _Kind, sort_by: str) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
boundary = _CREATED_AT.replace(second=59)
|
||||
expected = _conversation(harness, created_at=boundary, updated_at=boundary)
|
||||
_conversation(
|
||||
harness, created_at=boundary + timedelta(microseconds=1), updated_at=boundary + timedelta(microseconds=1)
|
||||
)
|
||||
_conversation(harness, created_at=_CREATED_AT - timedelta(seconds=1), updated_at=_CREATED_AT - timedelta(seconds=1))
|
||||
assert _ids(
|
||||
harness.get(kind, query={"start": "2024-01-15 14:30", "end": "2024-01-15 14:30", "sort_by": sort_by})
|
||||
) == [expected.id]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
def test_empty_dates_remain_unbounded(harness: _Harness, kind: _Kind) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
conversation = _conversation(harness)
|
||||
assert _ids(harness.get(kind, query={"start": "", "end": ""})) == [conversation.id]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["query", "answer"])
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
def test_keyword_is_case_insensitive_and_escapes_wildcards(harness: _Harness, kind: _Kind, field: str) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
matching = _conversation(
|
||||
harness,
|
||||
query="Literal 50%_done" if field == "query" else "First question",
|
||||
answer="Literal 50%_done" if field == "answer" else "First answer",
|
||||
)
|
||||
_conversation(harness, query="50XYZdone", answer="50XYZdone")
|
||||
assert _ids(harness.get(kind, query={"keyword": "50%_DONE"})) == [matching.id]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filter_value", ["annotated", "not_annotated"])
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
def test_annotation_filter(harness: _Harness, kind: _Kind, filter_value: str) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
annotated = _conversation(harness)
|
||||
plain = _conversation(harness)
|
||||
with harness.factory.begin() as session:
|
||||
session.add(
|
||||
MessageAnnotation(
|
||||
app_id=harness.target.id,
|
||||
conversation_id=annotated.id,
|
||||
account_id=harness.account.id,
|
||||
content="Annotation",
|
||||
question="Question",
|
||||
)
|
||||
)
|
||||
assert _ids(harness.get(kind, query={"annotation_status": filter_value})) == [
|
||||
annotated.id if filter_value == "annotated" else plain.id
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
def test_chat_modes_and_debugger_visibility(harness: _Harness, mode: AppMode) -> None:
|
||||
harness.mode(mode)
|
||||
published = _conversation(harness)
|
||||
debugger = _conversation(harness, invoke_from="debugger")
|
||||
expected = {published.id} if mode == AppMode.ADVANCED_CHAT else {published.id, debugger.id}
|
||||
assert set(_ids(harness.get("chat"))) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "mode"), [("chat", AppMode.COMPLETION), ("chat", AppMode.WORKFLOW), ("completion", AppMode.CHAT)]
|
||||
)
|
||||
def test_unavailable_app_is_rejected_before_query_validation(harness: _Harness, kind: _Kind, mode: AppMode) -> None:
|
||||
harness.mode(mode)
|
||||
response = harness.get(kind, query={"limit": 0})
|
||||
assert response.status_code == 404
|
||||
assert response.get_json()["code"] == "app_not_found"
|
||||
assert "App mode is not in the supported list" in response.get_json()["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
def test_another_tenants_app_is_not_visible(harness: _Harness, kind: _Kind) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
with harness.factory.begin() as session:
|
||||
target = session.get(App, harness.target.id)
|
||||
assert target is not None
|
||||
target.tenant_id = str(uuid4())
|
||||
response = harness.get(kind)
|
||||
assert response.status_code == 404
|
||||
assert response.get_json()["code"] == "app_not_found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[
|
||||
TenantAccountRole.OWNER,
|
||||
TenantAccountRole.ADMIN,
|
||||
TenantAccountRole.EDITOR,
|
||||
TenantAccountRole.NORMAL,
|
||||
TenantAccountRole.DATASET_OPERATOR,
|
||||
],
|
||||
)
|
||||
def test_existing_edit_roles_are_preserved(harness: _Harness, role: TenantAccountRole) -> None:
|
||||
with harness.factory.begin() as session:
|
||||
membership = session.scalar(select(TenantAccountJoin).where(TenantAccountJoin.account_id == harness.account.id))
|
||||
assert membership is not None
|
||||
membership.role = role
|
||||
response = harness.get("chat")
|
||||
assert response.status_code == (200 if TenantAccountRole.is_editing_role(role) else 403)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
def test_login_and_csrf_remain_required(harness: _Harness, kind: _Kind) -> None:
|
||||
assert harness.get(kind, authenticated=False).status_code == 401
|
||||
assert harness.get(kind, csrf=False).status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize("handler", [ChatConversationApi.get, CompletionConversationApi.get])
|
||||
def test_app_view_layout_rbac_check_is_declared(handler: Callable[..., object]) -> None:
|
||||
checks: tuple[RBACCheck, ...] = vars(handler)[RBAC_CHECKS_ATTR]
|
||||
assert len(checks) == 1
|
||||
assert checks[0].scene == RBACPermission.APP_VIEW_LAYOUT
|
||||
assert isinstance(checks[0].locator, PlainApp)
|
||||
assert checks[0].locator.param == "app_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["chat", "completion"])
|
||||
def test_account_removed_after_authentication_is_unauthorized(harness: _Harness, kind: _Kind) -> None:
|
||||
harness.mode(AppMode.COMPLETION if kind == "completion" else AppMode.CHAT)
|
||||
|
||||
def delete_account() -> None:
|
||||
with harness.factory.begin() as session:
|
||||
account = session.get(Account, harness.account.id)
|
||||
assert account is not None
|
||||
session.delete(account)
|
||||
|
||||
harness.after_account_load = delete_account
|
||||
response = harness.get(kind)
|
||||
assert response.status_code == 401
|
||||
assert response.get_json()["code"] == "unauthorized"
|
||||
assert response.get_json()["message"]
|
||||
@@ -23,6 +23,7 @@ from repositories.conversation_repository import ConversationRepository
|
||||
from services.auth.resource_access_token_contracts import ResourceAccessTokenResource
|
||||
from services.conversation_query_service import ConversationQueryService
|
||||
from services.entities.conversation_entities import ConversationSort
|
||||
from services.message_query_adapters import MessageFileResolver
|
||||
from tests.e2e_tests.contracts.controllers.service_api.app import test_conversation_rename as rename_tests
|
||||
from tests.e2e_tests.contracts.controllers.service_api.app.test_conversation_rename import _Harness as RenameHarness
|
||||
from tests.e2e_tests.contracts.controllers.service_api.app.test_conversation_rename import _Services as RenameServices
|
||||
@@ -67,6 +68,7 @@ def harness(rename_harness: RenameHarness) -> _Harness:
|
||||
conversation_names=original.conversation_names,
|
||||
resource_access_tokens=original.resource_access_tokens,
|
||||
conversation_queries=ConversationQueryService(
|
||||
files=MessageFileResolver(),
|
||||
conversations=ConversationRepository(session_factory=rename_harness.repository_factory),
|
||||
restore_inputs=restore_input_files,
|
||||
),
|
||||
|
||||
@@ -22,6 +22,7 @@ from models.web import PinnedConversation
|
||||
from repositories.conversation_repository import ConversationRepository
|
||||
from services.conversation_query_service import ConversationQueryService
|
||||
from services.entities.conversation_entities import ConversationSort
|
||||
from services.message_query_adapters import MessageFileResolver
|
||||
from tests.e2e_tests.contracts.controllers.web import test_conversation_rename as rename_tests
|
||||
from tests.e2e_tests.contracts.controllers.web.test_conversation_rename import _Harness as RenameHarness
|
||||
from tests.helpers.unit.model_factories import make_conversation
|
||||
@@ -64,6 +65,7 @@ def harness(rename_harness: RenameHarness) -> _Harness:
|
||||
|
||||
rename_harness.app.extensions["application_services"] = _Services(
|
||||
conversation_queries=ConversationQueryService(
|
||||
files=MessageFileResolver(),
|
||||
conversations=ConversationRepository(session_factory=rename_harness.repository_factory),
|
||||
restore_inputs=restore,
|
||||
)
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from inspect import unwrap
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from controllers.console.app import conversation as conversation_module
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from fields.conversation_fields import ConversationResponseSource
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models import Account, App, EndUser, Message, MessageAnnotation, MessageFeedback
|
||||
from models import Account, EndUser, Message, MessageAnnotation, MessageFeedback
|
||||
from models.enums import (
|
||||
ConversationFromSource,
|
||||
CreatorUserRole,
|
||||
EndUserType,
|
||||
FeedbackFromSource,
|
||||
FeedbackRating,
|
||||
InvokeFrom,
|
||||
WorkflowRunTriggeredFrom,
|
||||
)
|
||||
from models.model import AppMode, Conversation
|
||||
from models.workflow import WorkflowRun, WorkflowType
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from tests.helpers.unit.model_factories import make_app, make_conversation
|
||||
from tests.helpers.unit.model_factories import make_conversation
|
||||
|
||||
|
||||
def _make_account() -> Account:
|
||||
@@ -35,10 +27,6 @@ def _make_account() -> Account:
|
||||
return account
|
||||
|
||||
|
||||
def _app(*, mode: AppMode = AppMode.CHAT) -> App:
|
||||
return make_app(name="Conversation app", mode=mode)
|
||||
|
||||
|
||||
def _conversation(*, conversation_id: str = "c1", app_id: str = "app-1") -> Conversation:
|
||||
return make_conversation(
|
||||
conversation_id=conversation_id,
|
||||
@@ -54,97 +42,6 @@ def _conversation(*, conversation_id: str = "c1", app_id: str = "app-1") -> Conv
|
||||
)
|
||||
|
||||
|
||||
def test_completion_conversation_list_returns_paginated_result(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
||||
) -> None:
|
||||
api = conversation_module.CompletionConversationApi()
|
||||
method = unwrap(api.get)
|
||||
account = _make_account()
|
||||
monkeypatch.setattr(conversation_module, "parse_time_range", lambda *_args, **_kwargs: (None, None))
|
||||
paginate_result = MagicMock()
|
||||
paginate_result.page = 1
|
||||
paginate_result.per_page = 20
|
||||
paginate_result.total = 0
|
||||
paginate_result.has_next = False
|
||||
paginate_result.items = []
|
||||
monkeypatch.setattr(conversation_module, "paginate_query", lambda *_args, **_kwargs: paginate_result)
|
||||
with app.test_request_context("/console/api/apps/app-1/completion-conversations", method="GET"):
|
||||
response = method(
|
||||
api,
|
||||
conversation_module.CompletionConversationQuery(),
|
||||
unbound_session,
|
||||
account,
|
||||
app_model=_app(mode=AppMode.COMPLETION),
|
||||
)
|
||||
assert response == {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []}
|
||||
|
||||
|
||||
def test_completion_conversation_list_invalid_time_range(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
||||
) -> None:
|
||||
api = conversation_module.CompletionConversationApi()
|
||||
method = unwrap(api.get)
|
||||
account = _make_account()
|
||||
monkeypatch.setattr(
|
||||
conversation_module,
|
||||
"parse_time_range",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("bad range")),
|
||||
)
|
||||
with app.test_request_context(
|
||||
"/console/api/apps/app-1/completion-conversations", method="GET", query_string={"start": "bad"}
|
||||
):
|
||||
with pytest.raises(BadRequest):
|
||||
method(
|
||||
api,
|
||||
conversation_module.CompletionConversationQuery(),
|
||||
unbound_session,
|
||||
account,
|
||||
app_model=_app(mode=AppMode.COMPLETION),
|
||||
)
|
||||
|
||||
|
||||
def test_chat_conversation_list_advanced_chat_calls_paginate(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
||||
) -> None:
|
||||
api = conversation_module.ChatConversationApi()
|
||||
method = unwrap(api.get)
|
||||
account = _make_account()
|
||||
monkeypatch.setattr(conversation_module, "parse_time_range", lambda *_args, **_kwargs: (None, None))
|
||||
paginate_result = MagicMock()
|
||||
paginate_result.page = 1
|
||||
paginate_result.per_page = 20
|
||||
paginate_result.total = 0
|
||||
paginate_result.has_next = False
|
||||
paginate_result.items = []
|
||||
monkeypatch.setattr(conversation_module, "paginate_query", lambda *_args, **_kwargs: paginate_result)
|
||||
with app.test_request_context("/console/api/apps/app-1/chat-conversations", method="GET"):
|
||||
response = method(
|
||||
api,
|
||||
conversation_module.ChatConversationQuery(),
|
||||
unbound_session,
|
||||
account,
|
||||
app_model=_app(mode=AppMode.ADVANCED_CHAT),
|
||||
)
|
||||
assert response == {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []}
|
||||
|
||||
|
||||
def test_get_conversation_updates_read_at(sqlite_session: Session) -> None:
|
||||
conversation = _conversation()
|
||||
sqlite_session.add(conversation)
|
||||
sqlite_session.flush()
|
||||
session = sqlite_session
|
||||
result = conversation_module._get_conversation(session, _make_account(), _app(), "c1")
|
||||
assert result is conversation
|
||||
assert conversation.read_at is not None
|
||||
assert conversation.read_account_id == "u1"
|
||||
|
||||
|
||||
def test_get_conversation_missing_raises_not_found(sqlite_session: Session) -> None:
|
||||
session = sqlite_session
|
||||
with pytest.raises(NotFound):
|
||||
conversation_module._get_conversation(session, _make_account(), _app(), "missing")
|
||||
|
||||
|
||||
def test_conversation_response_source_uses_caller_session(sqlite_session: Session) -> None:
|
||||
account = _make_account()
|
||||
end_user = EndUser(
|
||||
@@ -240,7 +137,7 @@ def test_conversation_response_source_uses_caller_session(sqlite_session: Sessio
|
||||
sqlite_session.add_all([account, end_user, conversation, message, annotation, workflow_run, *feedbacks])
|
||||
sqlite_session.commit()
|
||||
|
||||
source = conversation_module.ConversationResponseSource(conversation, session=sqlite_session)
|
||||
source = ConversationResponseSource(conversation, session=sqlite_session)
|
||||
|
||||
assert source.inputs == {"topic": "support"}
|
||||
assert source.model_config == {"model_id": None, "provider": None}
|
||||
@@ -256,124 +153,3 @@ def test_conversation_response_source_uses_caller_session(sqlite_session: Sessio
|
||||
assert source.first_message is not None
|
||||
assert source.from_end_user_session_id == "end-user-session"
|
||||
assert source.from_account_name == "Account"
|
||||
|
||||
|
||||
def test_completion_conversation_delete_maps_not_found(
|
||||
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
||||
) -> None:
|
||||
api = conversation_module.CompletionConversationDetailApi()
|
||||
method = unwrap(api.delete)
|
||||
monkeypatch.setattr(
|
||||
conversation_module.ConversationService,
|
||||
"delete",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(ConversationNotExistsError()),
|
||||
)
|
||||
session = unbound_session
|
||||
with pytest.raises(NotFound):
|
||||
method(api, session, _make_account(), app_model=_app(), conversation_id="c1")
|
||||
|
||||
|
||||
# ── Regression tests for the < vs <= end-time boundary bug ──────────────────
|
||||
|
||||
|
||||
def _captured_where_clauses(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
app: Flask,
|
||||
endpoint_class,
|
||||
query_instance,
|
||||
app_mode,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Run the endpoint's GET handler with a fixed end_datetime and capture the
|
||||
SQLAlchemy WHERE clause strings that were applied so we can assert on the
|
||||
comparison operator used for the end-time boundary.
|
||||
|
||||
``query_instance`` is passed directly to the unwrapped handler, so callers
|
||||
can pre-configure it (e.g. ``ChatConversationQuery(sort_by="created_at")``).
|
||||
"""
|
||||
# 2024-01-15 14:30:00 UTC — parse_time_range returns second=0; the handler
|
||||
# then calls .replace(second=59) giving 14:30:59.
|
||||
boundary_dt = datetime.datetime(2024, 1, 15, 14, 30, 0)
|
||||
|
||||
monkeypatch.setattr(
|
||||
conversation_module,
|
||||
"parse_time_range",
|
||||
lambda *_args, **_kwargs: (None, boundary_dt),
|
||||
)
|
||||
|
||||
captured: list[str] = []
|
||||
original_where = sa.sql.selectable.Select.where
|
||||
|
||||
def spy_where(self, *criteria, **kwargs):
|
||||
for c in criteria:
|
||||
captured.append(str(c.compile(compile_kwargs={"literal_binds": True})))
|
||||
return original_where(self, *criteria, **kwargs)
|
||||
|
||||
paginate_result = MagicMock()
|
||||
paginate_result.page = 1
|
||||
paginate_result.per_page = 20
|
||||
paginate_result.total = 0
|
||||
paginate_result.has_next = False
|
||||
paginate_result.items = []
|
||||
monkeypatch.setattr(conversation_module, "paginate_query", lambda *_args, **_kwargs: paginate_result)
|
||||
|
||||
api = endpoint_class()
|
||||
method = unwrap(api.get)
|
||||
account = _make_account()
|
||||
|
||||
with Session() as session, app.test_request_context("/", method="GET"):
|
||||
with patch.object(sa.sql.selectable.Select, "where", spy_where):
|
||||
method(
|
||||
api,
|
||||
query_instance,
|
||||
session,
|
||||
account,
|
||||
app_model=_app(mode=app_mode),
|
||||
)
|
||||
|
||||
return captured
|
||||
|
||||
|
||||
def test_completion_conversation_end_time_filter_is_inclusive(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""CompletionConversationApi must use <= (not <) for the end-time boundary.
|
||||
|
||||
Regression test for the bug where `created_at < end_datetime_utc` was used
|
||||
instead of `created_at <= end_datetime_utc`, silently dropping conversations
|
||||
created at exactly the boundary second (HH:MM:59).
|
||||
"""
|
||||
clauses = _captured_where_clauses(
|
||||
monkeypatch,
|
||||
app,
|
||||
conversation_module.CompletionConversationApi,
|
||||
conversation_module.CompletionConversationQuery(),
|
||||
AppMode.COMPLETION,
|
||||
)
|
||||
# Filter by the boundary timestamp value — field name not assumed.
|
||||
end_clauses = [c for c in clauses if "14:30:59" in c]
|
||||
assert end_clauses, "No end-time WHERE clause found for CompletionConversationApi"
|
||||
for clause in end_clauses:
|
||||
assert "<=" in clause, f"CompletionConversationApi end-time filter must use <= (inclusive), got: {clause!r}"
|
||||
assert clause.count("<") == 1, f"Expected exactly one <= operator (no stray < without =), got: {clause!r}"
|
||||
|
||||
|
||||
def test_chat_conversation_end_time_filter_is_inclusive(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ChatConversationApi must use <= for the end-time boundary (control test).
|
||||
|
||||
ChatConversationQuery defaults to sort_by='-updated_at', which routes the
|
||||
end-time filter through the updated_at branch. We explicitly request
|
||||
sort_by='created_at' to exercise the created_at branch and confirm it also
|
||||
uses <=, mirroring what CompletionConversationApi does after the fix.
|
||||
"""
|
||||
clauses = _captured_where_clauses(
|
||||
monkeypatch,
|
||||
app,
|
||||
conversation_module.ChatConversationApi,
|
||||
# Use created_at sort so the WHERE clause hits created_at, not updated_at.
|
||||
conversation_module.ChatConversationQuery(sort_by="created_at"),
|
||||
AppMode.ADVANCED_CHAT,
|
||||
)
|
||||
# Filter by the boundary timestamp value — field name not assumed.
|
||||
end_clauses = [c for c in clauses if "14:30:59" in c]
|
||||
assert end_clauses, "No end-time WHERE clause found for ChatConversationApi"
|
||||
for clause in end_clauses:
|
||||
assert "<=" in clause, f"ChatConversationApi end-time filter must use <= (inclusive), got: {clause!r}"
|
||||
@@ -24,6 +24,7 @@ from services.conversation_command_service import ConversationCommandService
|
||||
from services.conversation_name_service import ConversationNameService
|
||||
from services.conversation_query_service import ConversationQueryService
|
||||
from services.installed_app_conversation_service import InstalledAppConversationService
|
||||
from services.message_query_adapters import MessageFileResolver
|
||||
from tests.unit_tests.controllers.console.explore.test_installed_app_admission import (
|
||||
_assert_json_response,
|
||||
_Harness,
|
||||
@@ -122,7 +123,9 @@ def conversations(
|
||||
installed_apps=_InstalledAppServices(
|
||||
conversations=InstalledAppConversationService(
|
||||
queries=ConversationQueryService(
|
||||
conversations=ConversationRepository(session_factory=factory), restore_inputs=restore_input_files
|
||||
files=MessageFileResolver(),
|
||||
conversations=ConversationRepository(session_factory=factory),
|
||||
restore_inputs=restore_input_files,
|
||||
),
|
||||
names=ConversationNameService(
|
||||
conversations=ConversationRepository(session_factory=factory), generate_name=state.generate_name
|
||||
|
||||
@@ -21,7 +21,6 @@ from fields._value_type_serializer import serialize_value_type
|
||||
from graphon.variables import StringSegment
|
||||
from graphon.variables.types import SegmentType
|
||||
from models.model import App, AppMode
|
||||
from services.conversation_service import ConversationService
|
||||
|
||||
"""
|
||||
Unit tests for Service API Conversation controllers.
|
||||
@@ -426,14 +425,6 @@ class TestConversationCases:
|
||||
error = services.errors.conversation.ConversationVariableTypeMismatchError("Type mismatch")
|
||||
assert isinstance(error, services.errors.conversation.ConversationVariableTypeMismatchError)
|
||||
|
||||
class TestConversationService:
|
||||
"""Test ConversationService integration patterns."""
|
||||
|
||||
def test_delete_method_exists(self):
|
||||
"""Test that ConversationService.delete exists."""
|
||||
assert hasattr(ConversationService, "delete")
|
||||
assert callable(ConversationService.delete)
|
||||
|
||||
class TestConversationPayloadsController:
|
||||
def test_rename_requires_name(self) -> None:
|
||||
with pytest.raises(
|
||||
|
||||
@@ -329,6 +329,7 @@ def test_build_application_services_preserves_composed_boundaries(
|
||||
assert services.dataset_api_keys._access is services.knowledge.datasets._dataset_access
|
||||
assert isinstance(services.oauth_device, OAuthDeviceApplicationService)
|
||||
assert register_script.call_count == 3
|
||||
assert services.conversation_queries._files is services.message_queries._files
|
||||
|
||||
assert isinstance(services.installed_apps.generation, InstalledAppGenerationService)
|
||||
installed_apps = services.installed_apps.access._installed_apps
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,13 +1,21 @@
|
||||
"""Conversation commands exercise persistence, ownership and cleanup ordering."""
|
||||
|
||||
from dataclasses import replace
|
||||
from dataclasses import dataclass, replace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, delete, event, select, update
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.agent import AgentWorkingResourceStatus, AgentWorkspace, AgentWorkspaceOwnerType
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account
|
||||
from models.agent import (
|
||||
AgentConfigVersionKind,
|
||||
AgentWorkingResourceStatus,
|
||||
AgentWorkspace,
|
||||
AgentWorkspaceBinding,
|
||||
AgentWorkspaceOwnerType,
|
||||
)
|
||||
from models.enums import ConversationFromSource, CreatorUserRole, EndUserType
|
||||
from models.model import App, AppMode, Conversation, EndUser
|
||||
from models.web import PinnedConversation
|
||||
@@ -285,3 +293,172 @@ def test_end_user_existing_pin_and_unpin_do_not_require_live_conversation(
|
||||
service.set_pinned(ref=end_user_ref, is_pinned=False)
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.scalars(select(PinnedConversation)).all() == []
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConsoleDeletionData:
|
||||
context: RequestContext
|
||||
app_id: str
|
||||
conversation_id: str
|
||||
workspace_ids: tuple[str, ...]
|
||||
binding_ids: tuple[str, ...]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_deletion(sqlite_session_factory: sessionmaker[Session]) -> ConsoleDeletionData:
|
||||
context = RequestContext(
|
||||
request_id="console-delete", trace_id=None, account_id=str(uuid4()), active_workspace_id=str(uuid4())
|
||||
)
|
||||
with sqlite_session_factory.begin() as session:
|
||||
app = App(
|
||||
tenant_id=context.active_workspace_id, name="Chat", mode=AppMode.CHAT, enable_api=True, enable_site=True
|
||||
)
|
||||
account = Account(name="Owner", email="owner@example.com")
|
||||
account.id = context.account_id
|
||||
session.add_all([app, account])
|
||||
session.flush()
|
||||
conversation = Conversation(
|
||||
app_id=app.id,
|
||||
mode=AppMode.CHAT,
|
||||
name="Console conversation",
|
||||
_inputs={},
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=context.account_id,
|
||||
from_end_user_id=None,
|
||||
is_deleted=False,
|
||||
)
|
||||
session.add(conversation)
|
||||
session.flush()
|
||||
workspaces: list[AgentWorkspace] = []
|
||||
bindings: list[AgentWorkspaceBinding] = []
|
||||
for owner_scope_key in ("root", "node-1:workflow-binding"):
|
||||
workspace = AgentWorkspace(
|
||||
id=str(uuid4()),
|
||||
tenant_id=app.tenant_id,
|
||||
app_id=app.id,
|
||||
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
|
||||
owner_id=conversation.id,
|
||||
owner_scope_key=owner_scope_key,
|
||||
backend_workspace_ref=owner_scope_key,
|
||||
status=AgentWorkingResourceStatus.ACTIVE,
|
||||
active_guard=1,
|
||||
)
|
||||
binding = AgentWorkspaceBinding(
|
||||
id=str(uuid4()),
|
||||
tenant_id=app.tenant_id,
|
||||
app_id=app.id,
|
||||
workspace_id=workspace.id,
|
||||
agent_id=str(uuid4()),
|
||||
agent_config_version_id=str(uuid4()),
|
||||
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
backend_binding_ref=owner_scope_key,
|
||||
status=AgentWorkingResourceStatus.ACTIVE,
|
||||
)
|
||||
session.add_all([workspace, binding])
|
||||
workspaces.append(workspace)
|
||||
bindings.append(binding)
|
||||
conversation.agent_workspace_binding_id = bindings[0].id
|
||||
return ConsoleDeletionData(
|
||||
context=context,
|
||||
app_id=app.id,
|
||||
conversation_id=conversation.id,
|
||||
workspace_ids=tuple(workspace.id for workspace in workspaces),
|
||||
binding_ids=tuple(binding.id for binding in bindings),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("has_root_binding", [False, True])
|
||||
def test_console_delete_retires_all_workspaces_and_returns_connection_before_cleanup(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
sqlite_engine: Engine,
|
||||
console_deletion: ConsoleDeletionData,
|
||||
has_root_binding: bool,
|
||||
) -> None:
|
||||
data = console_deletion
|
||||
if not has_root_binding:
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.execute(
|
||||
update(Conversation)
|
||||
.where(Conversation.id == data.conversation_id)
|
||||
.values(agent_workspace_binding_id=None)
|
||||
)
|
||||
events: list[str] = []
|
||||
|
||||
@event.listens_for(sqlite_engine, "checkout")
|
||||
def checkout(_connection: object, _record: object, _proxy: object) -> None:
|
||||
events.append("checkout")
|
||||
|
||||
@event.listens_for(sqlite_engine, "checkin")
|
||||
def checkin(_connection: object, _record: object) -> None:
|
||||
events.append("checkin")
|
||||
|
||||
def cleanup(*, tenant_id: str, conversation_id: str, retired_workspace_ids: tuple[str, ...]) -> None:
|
||||
assert events == ["checkout", "checkin"]
|
||||
assert (tenant_id, conversation_id) == (data.context.active_workspace_id, data.conversation_id)
|
||||
assert set(retired_workspace_ids) == set(data.workspace_ids)
|
||||
events.append("cleanup")
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.get_one(Conversation, conversation_id).is_deleted is True
|
||||
for workspace_id in data.workspace_ids:
|
||||
workspace = session.get_one(AgentWorkspace, workspace_id)
|
||||
assert workspace.status == AgentWorkingResourceStatus.RETIRED
|
||||
assert workspace.active_guard is None
|
||||
assert workspace.retired_at is not None
|
||||
for binding_id in data.binding_ids:
|
||||
binding = session.get_one(AgentWorkspaceBinding, binding_id)
|
||||
assert binding.status == AgentWorkingResourceStatus.RETIRED
|
||||
assert binding.retired_at is not None
|
||||
|
||||
service = _service(sqlite_session_factory, cleanup=cleanup)
|
||||
service.delete_console(data.context, data.app_id, data.conversation_id, kind="chat")
|
||||
assert events == ["checkout", "checkin", "cleanup", "checkout", "checkin"]
|
||||
with pytest.raises(ConversationNotExistsError):
|
||||
service.delete_console(data.context, data.app_id, data.conversation_id, kind="chat")
|
||||
assert events.count("cleanup") == 1
|
||||
|
||||
|
||||
def test_console_delete_failed_commit_rolls_back_root_and_node_resources_without_cleanup(
|
||||
sqlite_session_factory: sessionmaker[Session], sqlite_engine: Engine, console_deletion: ConsoleDeletionData
|
||||
) -> None:
|
||||
data = console_deletion
|
||||
factory = sessionmaker(bind=sqlite_engine)
|
||||
|
||||
@event.listens_for(factory, "before_commit")
|
||||
def fail_commit(session: Session) -> None:
|
||||
# Flush actual writes before the failure to exercise transaction rollback.
|
||||
session.flush()
|
||||
raise RuntimeError("Database commit failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Database commit failed"):
|
||||
_service(factory).delete_console(data.context, data.app_id, data.conversation_id, kind="chat")
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.get_one(Conversation, data.conversation_id).is_deleted is False
|
||||
for workspace_id in data.workspace_ids:
|
||||
workspace = session.get_one(AgentWorkspace, workspace_id)
|
||||
assert workspace.status == AgentWorkingResourceStatus.ACTIVE
|
||||
assert workspace.active_guard == 1
|
||||
assert workspace.retired_at is None
|
||||
for binding_id in data.binding_ids:
|
||||
binding = session.get_one(AgentWorkspaceBinding, binding_id)
|
||||
assert binding.status == AgentWorkingResourceStatus.ACTIVE
|
||||
assert binding.retired_at is None
|
||||
|
||||
|
||||
def test_console_cleanup_failure_cannot_rollback_committed_deletion(
|
||||
sqlite_session_factory: sessionmaker[Session], console_deletion: ConsoleDeletionData
|
||||
) -> None:
|
||||
data = console_deletion
|
||||
|
||||
def cleanup(*, tenant_id: str, conversation_id: str, retired_workspace_ids: tuple[str, ...]) -> None:
|
||||
assert (tenant_id, conversation_id) == (data.context.active_workspace_id, data.conversation_id)
|
||||
assert set(retired_workspace_ids) == set(data.workspace_ids)
|
||||
raise RuntimeError("Cleanup transport unavailable")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Cleanup transport unavailable"):
|
||||
_service(sqlite_session_factory, cleanup=cleanup).delete_console(
|
||||
data.context, data.app_id, data.conversation_id, kind="chat"
|
||||
)
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.get_one(Conversation, data.conversation_id).is_deleted is True
|
||||
for workspace_id in data.workspace_ids:
|
||||
assert session.get_one(AgentWorkspace, workspace_id).status == AgentWorkingResourceStatus.RETIRED
|
||||
@@ -1,22 +1,41 @@
|
||||
"""Conversation listing materializes data before restoring file inputs."""
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import Callable, Generator, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from threading import Thread
|
||||
from typing import override
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import JsonValue
|
||||
from sqlalchemy import Engine, event, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy import Connection, Engine, event, select
|
||||
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
||||
|
||||
from graphon.file import FILE_MODEL_IDENTITY, File
|
||||
from models.enums import ConversationFromSource, EndUserType, InvokeFrom
|
||||
from models.model import App, AppMode, Conversation, EndUser, InstalledApp
|
||||
from core.helper.http_client_pooling import close_all_pooled_clients
|
||||
from core.tools.signature import verify_tool_file_signature
|
||||
from core.workflow.file_reference import build_file_reference, resolve_file_record_id
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from fields.conversation_fields import ConversationMessageDetail
|
||||
from fields.conversation_fields import MessageFile as MessageFileResponse
|
||||
from graphon.file import FILE_MODEL_IDENTITY, File, FileTransferMethod, FileType
|
||||
from machinery.context import RequestContext
|
||||
from models import MessageFile, ToolFile, UploadFile
|
||||
from models.enums import ConversationFromSource, CreatorUserRole, EndUserType, InvokeFrom, MessageFileBelongsTo
|
||||
from models.model import App, AppMode, Conversation, EndUser, InstalledApp, Message
|
||||
from models.utils.file_input_compat import restore_input_files
|
||||
from repositories.conversation_repository import ConversationRepository
|
||||
from services.conversation_query_service import ConversationQueryService
|
||||
from services.entities.conversation_entities import (
|
||||
ConsoleCompletionConversationDetail,
|
||||
ConsoleCompletionConversationRecord,
|
||||
ConsoleConversationFilters,
|
||||
ConsoleConversationScope,
|
||||
ConversationInputValue,
|
||||
ConversationListSource,
|
||||
ConversationScope,
|
||||
@@ -24,6 +43,8 @@ from services.entities.conversation_entities import (
|
||||
InstalledAppConversationScope,
|
||||
)
|
||||
from services.installed_app_access_service import InstalledAppRef
|
||||
from services.message_query_adapters import MessageFileResolver
|
||||
from tests.helpers.unit.model_factories import make_account, make_app, make_conversation, make_message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -145,7 +166,9 @@ def test_query_restores_real_file_inputs_after_returning_database_connection(
|
||||
return restore_input_files(inputs=inputs, tenant_resolver=tenant_resolver)
|
||||
|
||||
service = ConversationQueryService(
|
||||
conversations=ConversationRepository(session_factory=sqlite_session_factory), restore_inputs=restore
|
||||
files=MessageFileResolver(),
|
||||
conversations=ConversationRepository(session_factory=sqlite_session_factory),
|
||||
restore_inputs=restore,
|
||||
)
|
||||
result = service.get_page(
|
||||
scope=query_context.scope,
|
||||
@@ -226,7 +249,9 @@ def test_invalid_file_input_error_propagates_after_repository_connection_is_clos
|
||||
conversation.inputs = {"file": {"dify_model_identity": FILE_MODEL_IDENTITY, "transfer_method": "invalid"}}
|
||||
connection_events = _record_connections(sqlite_engine)
|
||||
service = ConversationQueryService(
|
||||
conversations=ConversationRepository(session_factory=sqlite_session_factory), restore_inputs=restore_input_files
|
||||
files=MessageFileResolver(),
|
||||
conversations=ConversationRepository(session_factory=sqlite_session_factory),
|
||||
restore_inputs=restore_input_files,
|
||||
)
|
||||
with pytest.raises(ValueError, match="'invalid' is not a valid FileTransferMethod"):
|
||||
service.get_page(
|
||||
@@ -242,3 +267,424 @@ def test_invalid_file_input_error_propagates_after_repository_connection_is_clos
|
||||
assert (
|
||||
session.scalar(select(Conversation.id).where(Conversation.id == query_context.conversation_id)) is not None
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_scope(sqlite_session_factory: sessionmaker[Session]) -> ConsoleConversationScope:
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add(make_account(timezone="Asia/Shanghai"))
|
||||
session.add(make_app(mode=AppMode.COMPLETION))
|
||||
session.add(make_conversation(mode=AppMode.COMPLETION, inputs={}, from_source=ConversationFromSource.API))
|
||||
session.add(
|
||||
make_message(
|
||||
inputs={},
|
||||
query="Question",
|
||||
message=[{"text": "Prompt"}],
|
||||
answer="Answer",
|
||||
message_unit_price=Decimal(0),
|
||||
answer_unit_price=Decimal(0),
|
||||
total_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
)
|
||||
)
|
||||
return ConversationRepository(session_factory=sqlite_session_factory).get_console_scope(
|
||||
RequestContext(request_id="test", trace_id=None, account_id="account-1", active_workspace_id="tenant-1"),
|
||||
"app-1",
|
||||
kind="completion",
|
||||
)
|
||||
|
||||
|
||||
def _console_filters() -> ConsoleConversationFilters:
|
||||
return ConsoleConversationFilters(
|
||||
keyword=None, start=None, end=None, annotation_status="all", sort_by="-created_at", page=1, limit=20
|
||||
)
|
||||
|
||||
|
||||
def test_console_message_file_inputs_restore_after_all_database_projections_complete(
|
||||
sqlite_session_factory: sessionmaker[Session], sqlite_engine: Engine, console_scope: ConsoleConversationScope
|
||||
) -> None:
|
||||
file_mapping: dict[str, JsonValue] = {
|
||||
"dify_model_identity": FILE_MODEL_IDENTITY,
|
||||
"transfer_method": "remote_url",
|
||||
"type": "document",
|
||||
"url": "https://example.com/report.pdf",
|
||||
"filename": "report.pdf",
|
||||
"extension": ".pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 1,
|
||||
}
|
||||
inputs: dict[str, ConversationInputValue] = {"file": file_mapping, "zero": 0, "none": None}
|
||||
with sqlite_session_factory.begin() as session:
|
||||
message = session.get_one(Message, "message-1")
|
||||
message.inputs = deepcopy(inputs)
|
||||
connection_events = _record_connections(sqlite_engine)
|
||||
commits: list[bool] = []
|
||||
|
||||
@event.listens_for(sqlite_session_factory, "after_commit")
|
||||
def committed(_session: Session) -> None:
|
||||
commits.append(True)
|
||||
|
||||
def restore(
|
||||
*, inputs: Mapping[str, ConversationInputValue], tenant_resolver: Callable[[], str]
|
||||
) -> dict[str, ConversationInputValue]:
|
||||
assert connection_events == ["checkout", "checkin"]
|
||||
assert tenant_resolver() == console_scope.tenant_id
|
||||
connection_events.append("restore")
|
||||
return restore_input_files(inputs=inputs, tenant_resolver=tenant_resolver)
|
||||
|
||||
service = ConversationQueryService(
|
||||
files=MessageFileResolver(),
|
||||
conversations=ConversationRepository(session_factory=sqlite_session_factory),
|
||||
restore_inputs=restore,
|
||||
)
|
||||
page = service.get_console_page(scope=console_scope, filters=_console_filters())
|
||||
|
||||
assert console_scope.timezone == "Asia/Shanghai"
|
||||
assert connection_events == ["checkout", "checkin", "restore"]
|
||||
assert commits == []
|
||||
record = page.items[0]
|
||||
assert isinstance(record, ConsoleCompletionConversationRecord)
|
||||
assert record.first_message is not None
|
||||
file = record.first_message.inputs["file"]
|
||||
assert isinstance(file, File)
|
||||
assert file.remote_url == "https://example.com/report.pdf"
|
||||
assert record.first_message.inputs["zero"] == 0
|
||||
assert record.first_message.inputs["none"] is None
|
||||
with sqlite_session_factory() as session:
|
||||
message = session.get_one(Message, "message-1")
|
||||
assert message._inputs == inputs
|
||||
|
||||
|
||||
def test_console_invalid_file_metadata_fails_after_connection_release(
|
||||
sqlite_session_factory: sessionmaker[Session], sqlite_engine: Engine, console_scope: ConsoleConversationScope
|
||||
) -> None:
|
||||
with sqlite_session_factory.begin() as session:
|
||||
message = session.get_one(Message, "message-1")
|
||||
message.inputs = {"file": {"dify_model_identity": FILE_MODEL_IDENTITY, "transfer_method": "invalid"}}
|
||||
connection_events = _record_connections(sqlite_engine)
|
||||
service = ConversationQueryService(
|
||||
files=MessageFileResolver(),
|
||||
conversations=ConversationRepository(session_factory=sqlite_session_factory),
|
||||
restore_inputs=restore_input_files,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="'invalid' is not a valid FileTransferMethod"):
|
||||
service.get_console_page(scope=console_scope, filters=_console_filters())
|
||||
|
||||
assert connection_events == ["checkout", "checkin"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _file_configuration(config_overrides: Callable[..., None]) -> Iterator[None]:
|
||||
config_overrides(
|
||||
FILES_URL="https://files.example.test",
|
||||
SECRET_KEY="conversation-detail-file-test",
|
||||
SSRF_PROXY_HTTP_URL="",
|
||||
SSRF_PROXY_HTTPS_URL="",
|
||||
SSRF_PROXY_ALL_URL="",
|
||||
)
|
||||
close_all_pooled_clients()
|
||||
yield
|
||||
close_all_pooled_clients()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DetailData:
|
||||
context: RequestContext
|
||||
app_id: str
|
||||
conversation_id: str
|
||||
message_id: str
|
||||
upload_id: str
|
||||
tool_id: str
|
||||
legacy_file_id: str
|
||||
inputs: dict[str, JsonValue]
|
||||
|
||||
|
||||
def _file_input(record_id: str, method: str) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"dify_model_identity": FILE_MODEL_IDENTITY,
|
||||
"transfer_method": method,
|
||||
"reference": build_file_reference(record_id=record_id),
|
||||
"type": "document",
|
||||
"filename": "stale.json",
|
||||
"mime_type": "application/json",
|
||||
"size": 999,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def detail_data(sqlite_session_factory: sessionmaker[Session], _file_configuration: None) -> DetailData:
|
||||
tenant_id, account_id, app_id = str(uuid4()), str(uuid4()), str(uuid4())
|
||||
conversation_id, message_id = str(uuid4()), str(uuid4())
|
||||
with sqlite_session_factory.begin() as session:
|
||||
upload = UploadFile(
|
||||
tenant_id=tenant_id,
|
||||
storage_type=StorageType.LOCAL,
|
||||
key="uploads/canonical.txt",
|
||||
name="canonical.txt",
|
||||
size=17,
|
||||
extension="txt",
|
||||
mime_type="text/plain",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account_id,
|
||||
created_at=datetime(2026, 10, 13),
|
||||
used=True,
|
||||
)
|
||||
tool = ToolFile(
|
||||
user_id=account_id,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=conversation_id,
|
||||
file_key="tools/result.pdf",
|
||||
mimetype="application/pdf",
|
||||
name="result.pdf",
|
||||
size=23,
|
||||
)
|
||||
session.add_all([upload, tool])
|
||||
session.flush()
|
||||
mapping = _file_input(upload.id, "local_file")
|
||||
inputs: dict[str, JsonValue] = {
|
||||
"upload": mapping,
|
||||
"tools": [_file_input(tool.id, "tool_file")],
|
||||
"mixed": [mapping, "leave unchanged"],
|
||||
"empty": [],
|
||||
"zero": 0,
|
||||
"false": False,
|
||||
"none": None,
|
||||
}
|
||||
session.add_all(
|
||||
[
|
||||
make_account(account_id=account_id),
|
||||
make_app(app_id=app_id, tenant_id=tenant_id, mode=AppMode.COMPLETION),
|
||||
make_conversation(
|
||||
conversation_id=conversation_id,
|
||||
app_id=app_id,
|
||||
mode=AppMode.COMPLETION,
|
||||
inputs={},
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account_id,
|
||||
),
|
||||
make_message(
|
||||
message_id=message_id,
|
||||
app_id=app_id,
|
||||
conversation_id=conversation_id,
|
||||
inputs=inputs,
|
||||
query="Files",
|
||||
message={"text": "Prompt"},
|
||||
answer="Answer",
|
||||
message_unit_price=Decimal(0),
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account_id,
|
||||
),
|
||||
]
|
||||
)
|
||||
legacy = MessageFile(
|
||||
message_id=message_id,
|
||||
type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.TOOL_FILE,
|
||||
url=f"https://old.example/files/tools/{tool.id}.pdf",
|
||||
upload_file_id=None,
|
||||
belongs_to=MessageFileBelongsTo.ASSISTANT,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account_id,
|
||||
)
|
||||
session.add_all(
|
||||
[
|
||||
legacy,
|
||||
MessageFile(
|
||||
message_id=message_id,
|
||||
type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
upload_file_id=upload.id,
|
||||
belongs_to=MessageFileBelongsTo.USER,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account_id,
|
||||
),
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
return DetailData(
|
||||
context=RequestContext(
|
||||
request_id="detail-test", trace_id=None, account_id=account_id, active_workspace_id=tenant_id
|
||||
),
|
||||
app_id=app_id,
|
||||
conversation_id=conversation_id,
|
||||
message_id=message_id,
|
||||
upload_id=upload.id,
|
||||
tool_id=tool.id,
|
||||
legacy_file_id=legacy.id,
|
||||
inputs=inputs,
|
||||
)
|
||||
|
||||
|
||||
def _detail(factory: sessionmaker[Session], data: DetailData) -> ConsoleCompletionConversationDetail:
|
||||
result = ConversationQueryService(
|
||||
conversations=ConversationRepository(session_factory=factory),
|
||||
restore_inputs=restore_input_files,
|
||||
files=MessageFileResolver(),
|
||||
).get_console_detail(data.context, data.app_id, data.conversation_id, kind="completion")
|
||||
assert isinstance(result, ConsoleCompletionConversationDetail)
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseReads:
|
||||
sessions: list[Session] = field(default_factory=list)
|
||||
active_counts: list[int] = field(default_factory=list)
|
||||
commits: list[Session] = field(default_factory=list)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _observe_reads(factory: sessionmaker[Session]) -> Generator[DatabaseReads]:
|
||||
observed = DatabaseReads()
|
||||
|
||||
def begin(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
||||
observed.sessions.append(session)
|
||||
observed.active_counts.append(sum(item.in_transaction() for item in observed.sessions))
|
||||
|
||||
def committed(session: Session) -> None:
|
||||
observed.commits.append(session)
|
||||
|
||||
event.listen(factory, "after_begin", begin)
|
||||
event.listen(factory, "after_commit", committed)
|
||||
try:
|
||||
yield observed
|
||||
finally:
|
||||
event.remove(factory, "after_begin", begin)
|
||||
event.remove(factory, "after_commit", committed)
|
||||
|
||||
|
||||
def test_detail_restores_files_without_overlapping_sessions_or_persisting_legacy_fallback(
|
||||
sqlite_session_factory: sessionmaker[Session], detail_data: DetailData
|
||||
) -> None:
|
||||
with _observe_reads(sqlite_session_factory) as reads:
|
||||
detail = _detail(sqlite_session_factory, detail_data)
|
||||
response = ConversationMessageDetail.model_validate(detail)
|
||||
assert len(reads.sessions) > 1
|
||||
assert set(reads.active_counts) == {1}
|
||||
assert reads.commits == []
|
||||
assert all(not session.in_transaction() and not session.identity_map for session in reads.sessions)
|
||||
message = detail.first_message
|
||||
assert message is not None
|
||||
upload = message.inputs["upload"]
|
||||
assert isinstance(upload, File)
|
||||
assert (upload.filename, upload.mime_type, upload.size) == ("canonical.txt", "text/plain", 17)
|
||||
assert resolve_file_record_id(upload.reference) == detail_data.upload_id
|
||||
tools = message.inputs["tools"]
|
||||
assert isinstance(tools, list)
|
||||
assert isinstance(tools[0], File)
|
||||
assert tools[0].filename == "result.pdf"
|
||||
assert message.inputs["mixed"] == detail_data.inputs["mixed"]
|
||||
assert message.inputs["empty"] == []
|
||||
assert message.inputs["zero"] == 0
|
||||
assert message.inputs["false"] is False
|
||||
assert message.inputs["none"] is None
|
||||
attachments = {attachment.filename: attachment for attachment in message.message_files}
|
||||
assert attachments["canonical.txt"].belongs_to == "user"
|
||||
assert attachments["canonical.txt"].size == 17
|
||||
assert attachments["result.pdf"].belongs_to == "assistant"
|
||||
assert attachments["result.pdf"].upload_file_id == detail_data.tool_id
|
||||
assert response.message is not None
|
||||
assert response.message.inputs["upload"]["filename"] == "canonical.txt"
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.get_one(MessageFile, detail_data.legacy_file_id).upload_file_id is None
|
||||
assert session.get_one(Message, detail_data.message_id)._inputs == detail_data.inputs
|
||||
assert session.get_one(Conversation, detail_data.conversation_id).read_at is None
|
||||
|
||||
|
||||
def test_remote_head_occurs_after_conversation_and_file_lookup_transactions_close(
|
||||
sqlite_session_factory: sessionmaker[Session], detail_data: DetailData
|
||||
) -> None:
|
||||
requests: list[tuple[str, str, bool]] = []
|
||||
with _observe_reads(sqlite_session_factory) as reads:
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
@override
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
pass
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
requests.append((self.command, self.path, any(session.in_transaction() for session in reads.sessions)))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Content-Length", "29")
|
||||
self.send_header("Content-Disposition", "attachment; filename*=UTF-8''server%20report.txt")
|
||||
self.end_headers()
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add(
|
||||
MessageFile(
|
||||
message_id=detail_data.message_id,
|
||||
type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.REMOTE_URL,
|
||||
url=f"http://127.0.0.1:{server.server_port}/download.bin",
|
||||
belongs_to=MessageFileBelongsTo.USER,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=detail_data.context.account_id,
|
||||
)
|
||||
)
|
||||
reads.sessions.clear()
|
||||
reads.active_counts.clear()
|
||||
reads.commits.clear()
|
||||
detail = _detail(sqlite_session_factory, detail_data)
|
||||
assert requests == [("HEAD", "/download.bin", False)]
|
||||
assert reads.sessions
|
||||
assert set(reads.active_counts) == {1}
|
||||
assert reads.commits == []
|
||||
assert all(not session.in_transaction() and not session.identity_map for session in reads.sessions)
|
||||
assert detail.first_message is not None
|
||||
remote = next(file for file in detail.first_message.message_files if file.filename == "server report.txt")
|
||||
assert (remote.mime_type, remote.size, remote.belongs_to) == ("text/plain", 29, "user")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
|
||||
def test_detail_attachment_fields_match_the_legacy_model_projection(
|
||||
sqlite_session_factory: sessionmaker[Session], detail_data: DetailData
|
||||
) -> None:
|
||||
detail = _detail(sqlite_session_factory, detail_data)
|
||||
assert detail.first_message is not None
|
||||
actual = {
|
||||
file.id: MessageFileResponse.model_validate(file).model_dump(exclude={"url"})
|
||||
for file in detail.first_message.message_files
|
||||
}
|
||||
with sqlite_session_factory() as session:
|
||||
message = session.get_one(Message, detail_data.message_id)
|
||||
expected = {
|
||||
file["id"]: MessageFileResponse.model_validate(file).model_dump(exclude={"url"})
|
||||
for file in message.message_files_with_session(session=session)
|
||||
}
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", ["{}", "`{}`", "[download]({})"])
|
||||
@pytest.mark.parametrize("kind", ["tool", "file-preview", "image-preview"])
|
||||
def test_detail_signs_answer_urls_without_rewriting_stored_answer(
|
||||
sqlite_session_factory: sessionmaker[Session], detail_data: DetailData, shape: str, kind: str
|
||||
) -> None:
|
||||
file_id = detail_data.tool_id if kind == "tool" else detail_data.upload_id
|
||||
path = f"/files/tools/{file_id}.pdf" if kind == "tool" else f"/files/{file_id}/{kind}"
|
||||
original = shape.format(f"http://internal.example{path}?timestamp=1&nonce=old&sign=old&as_attachment=true")
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.get_one(Message, detail_data.message_id).answer = original
|
||||
detail = _detail(sqlite_session_factory, detail_data)
|
||||
assert detail.first_message is not None
|
||||
signed = detail.first_message.answer.removeprefix("[download](").removesuffix(")").strip("`")
|
||||
parsed = urlsplit(signed)
|
||||
query = parse_qs(parsed.query)
|
||||
assert parsed.netloc == "files.example.test"
|
||||
assert parsed.path == (f"/files/tools/{file_id}.pdf" if kind == "tool" else f"/files/{file_id}/file-preview")
|
||||
assert query["as_attachment"] == ["true"]
|
||||
assert query["nonce"] != ["old"]
|
||||
assert query["timestamp"] != ["1"]
|
||||
assert verify_tool_file_signature(file_id, query["timestamp"][0], query["nonce"][0], query["sign"][0])
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.get_one(Message, detail_data.message_id).answer == original
|
||||
@@ -11,7 +11,6 @@ from dataclasses import replace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
@@ -164,93 +163,6 @@ def conversation_workspace(sqlite_session: Session) -> tuple[AgentWorkspace, Age
|
||||
return workspace, binding
|
||||
|
||||
|
||||
@pytest.mark.parametrize("has_root_binding", [False, True])
|
||||
def test_delete_retires_then_commits_before_enqueue(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
conversation_workspace: tuple[AgentWorkspace, AgentWorkspaceBinding],
|
||||
has_root_binding: bool,
|
||||
) -> None:
|
||||
workspace, binding = conversation_workspace
|
||||
app = ConversationServiceTestDataFactory.create_app()
|
||||
account = ConversationServiceTestDataFactory.create_account()
|
||||
conversation = ConversationServiceTestDataFactory.create_conversation()
|
||||
conversation.agent_workspace_binding_id = binding.id if has_root_binding else None
|
||||
node_workspace = AgentWorkspace(
|
||||
id="workspace-node",
|
||||
tenant_id=TENANT_ID,
|
||||
app_id=APP_ID,
|
||||
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
|
||||
owner_id=conversation.id,
|
||||
owner_scope_key="node-1:workflow-binding-1",
|
||||
backend_workspace_ref="backend-workspace-node",
|
||||
status=AgentWorkingResourceStatus.ACTIVE,
|
||||
active_guard=1,
|
||||
)
|
||||
sqlite_session.add_all([conversation, node_workspace])
|
||||
sqlite_session.commit()
|
||||
events: list[str] = []
|
||||
event.listen(sqlite_session, "after_commit", lambda _session: events.append("commit"))
|
||||
|
||||
def enqueue(*, tenant_id: str, workspace_ids: tuple[str, ...]) -> None:
|
||||
assert tenant_id == TENANT_ID
|
||||
assert set(workspace_ids) == {workspace.id, node_workspace.id}
|
||||
assert not sqlite_session.in_transaction()
|
||||
assert conversation.is_deleted is True
|
||||
assert binding.status == workspace.status == AgentWorkingResourceStatus.RETIRED
|
||||
assert binding.retired_at is not None
|
||||
assert workspace.retired_at is not None
|
||||
assert workspace.active_guard is None
|
||||
assert node_workspace.status == AgentWorkingResourceStatus.RETIRED
|
||||
assert node_workspace.retired_at is not None
|
||||
assert node_workspace.active_guard is None
|
||||
events.append("agent cleanup")
|
||||
|
||||
monkeypatch.setattr(conversation_service, "enqueue_agent_resource_collection", enqueue)
|
||||
delete_related = MagicMock(side_effect=lambda _conversation_id: events.append("conversation cleanup"))
|
||||
monkeypatch.setattr(conversation_service.delete_conversation_related_data, "delay", delete_related)
|
||||
|
||||
ConversationService.delete(app, conversation.id, account, session=sqlite_session)
|
||||
|
||||
assert events == ["commit", "agent cleanup", "conversation cleanup"]
|
||||
delete_related.assert_called_once_with(conversation.id)
|
||||
|
||||
|
||||
def test_delete_commit_failure_rolls_back_all_lifecycle_changes_without_enqueue(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
conversation_workspace: tuple[AgentWorkspace, AgentWorkspaceBinding],
|
||||
) -> None:
|
||||
workspace, binding = conversation_workspace
|
||||
app = ConversationServiceTestDataFactory.create_app()
|
||||
account = ConversationServiceTestDataFactory.create_account()
|
||||
conversation = ConversationServiceTestDataFactory.create_conversation()
|
||||
conversation.agent_workspace_binding_id = binding.id
|
||||
sqlite_session.add(conversation)
|
||||
sqlite_session.commit()
|
||||
|
||||
def fail_commit(session: Session) -> None:
|
||||
session.flush()
|
||||
raise RuntimeError("commit failed")
|
||||
|
||||
event.listen(sqlite_session, "before_commit", fail_commit, once=True)
|
||||
enqueue_collection = MagicMock()
|
||||
delete_related = MagicMock()
|
||||
monkeypatch.setattr(conversation_service, "enqueue_agent_resource_collection", enqueue_collection)
|
||||
monkeypatch.setattr(conversation_service.delete_conversation_related_data, "delay", delete_related)
|
||||
|
||||
with pytest.raises(RuntimeError, match="commit failed"):
|
||||
ConversationService.delete(app, conversation.id, account, session=sqlite_session)
|
||||
|
||||
assert conversation.is_deleted is False
|
||||
assert binding.status == workspace.status == AgentWorkingResourceStatus.ACTIVE
|
||||
assert binding.retired_at is None
|
||||
assert workspace.retired_at is None
|
||||
assert workspace.active_guard == 1
|
||||
enqueue_collection.assert_not_called()
|
||||
delete_related.assert_not_called()
|
||||
|
||||
|
||||
def test_retire_leaves_commit_and_cleanup_to_the_caller(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
@@ -325,27 +237,6 @@ def test_retire_rejects_participants_owned_by_another_scope(
|
||||
assert binding.status == workspace.status == AgentWorkingResourceStatus.ACTIVE
|
||||
|
||||
|
||||
def test_delete_keeps_soft_deleted_marker_when_dispatch_fails(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
app = ConversationServiceTestDataFactory.create_app()
|
||||
account = ConversationServiceTestDataFactory.create_account()
|
||||
conversation = ConversationServiceTestDataFactory.create_conversation()
|
||||
sqlite_session.add(conversation)
|
||||
sqlite_session.flush()
|
||||
monkeypatch.setattr(
|
||||
conversation_service.delete_conversation_related_data,
|
||||
"delay",
|
||||
MagicMock(side_effect=RuntimeError("broker unavailable")),
|
||||
)
|
||||
|
||||
ConversationService.delete(app, conversation.id, account, session=sqlite_session)
|
||||
|
||||
persisted = sqlite_session.get(Conversation, conversation.id)
|
||||
assert persisted is not None
|
||||
assert persisted.is_deleted is True
|
||||
|
||||
|
||||
def test_cleanup_propagates_agent_enqueue_failure_before_conversation_enqueue(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
conversation_service, "enqueue_agent_resource_collection", MagicMock(side_effect=RuntimeError("unavailable"))
|
||||
@@ -359,3 +250,24 @@ def test_cleanup_propagates_agent_enqueue_failure_before_conversation_enqueue(mo
|
||||
)
|
||||
|
||||
delete_related.assert_not_called()
|
||||
|
||||
|
||||
def test_cleanup_keeps_soft_deleted_marker_when_dispatch_fails(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
conversation = ConversationServiceTestDataFactory.create_conversation(is_deleted=True)
|
||||
sqlite_session.add(conversation)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(
|
||||
conversation_service.delete_conversation_related_data,
|
||||
"delay",
|
||||
MagicMock(side_effect=RuntimeError("broker unavailable")),
|
||||
)
|
||||
|
||||
ConversationService.enqueue_delete_cleanup(
|
||||
tenant_id=TENANT_ID, conversation_id=conversation.id, retired_workspace_ids=()
|
||||
)
|
||||
|
||||
persisted = sqlite_session.get(Conversation, conversation.id)
|
||||
assert persisted is not None
|
||||
assert persisted.is_deleted is True
|
||||
@@ -27,6 +27,7 @@ from services.installed_app_conversation_service import (
|
||||
ConversationNotChatAppError,
|
||||
InstalledAppConversationService,
|
||||
)
|
||||
from services.message_query_adapters import MessageFileResolver
|
||||
|
||||
_TIME = datetime(2024, 1, 1)
|
||||
|
||||
@@ -97,7 +98,9 @@ def _service(
|
||||
) -> InstalledAppConversationService:
|
||||
repository = ConversationRepository(session_factory=factory)
|
||||
return InstalledAppConversationService(
|
||||
queries=ConversationQueryService(conversations=repository, restore_inputs=restore_input_files),
|
||||
queries=ConversationQueryService(
|
||||
files=MessageFileResolver(), conversations=repository, restore_inputs=restore_input_files
|
||||
),
|
||||
names=ConversationNameService(conversations=repository, generate_name=generate_name),
|
||||
commands=ConversationCommandService(conversations=repository, enqueue_delete_cleanup=cleanup),
|
||||
)
|
||||
|
||||
+24
-4
@@ -202,6 +202,14 @@ const zUnsupportedAudioTypeError = /* @__PURE__ */ (() =>
|
||||
}),
|
||||
))()
|
||||
|
||||
const zConversationNotFoundError = /* @__PURE__ */ (() =>
|
||||
zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('conversation_not_found'),
|
||||
status: z.literal(404),
|
||||
}),
|
||||
))()
|
||||
|
||||
const zAppSuggestedQuestionsAfterAnswerDisabledError = /* @__PURE__ */ (() =>
|
||||
zErrorResponse.and(
|
||||
z.object({
|
||||
@@ -637,9 +645,15 @@ export const post18Errors = {
|
||||
unsupported_audio_type: { status: 415, data: zUnsupportedAudioTypeError },
|
||||
}
|
||||
|
||||
export const delete5Errors = { app_not_found: { status: 404, data: zAppNotFoundError } }
|
||||
export const delete5Errors = {
|
||||
app_not_found: { status: 404, data: zAppNotFoundError },
|
||||
conversation_not_found: { status: 404, data: zConversationNotFoundError },
|
||||
}
|
||||
|
||||
export const get24Errors = { app_not_found: { status: 404, data: zAppNotFoundError } }
|
||||
export const get24Errors = {
|
||||
app_not_found: { status: 404, data: zAppNotFoundError },
|
||||
conversation_not_found: { status: 404, data: zConversationNotFoundError },
|
||||
}
|
||||
|
||||
export const get25Errors = { app_not_found: { status: 404, data: zAppNotFoundError } }
|
||||
|
||||
@@ -664,9 +678,15 @@ export const get27Errors = {
|
||||
app_unavailable: { status: 400, data: zAppUnavailableError },
|
||||
}
|
||||
|
||||
export const delete6Errors = { app_not_found: { status: 404, data: zAppNotFoundError } }
|
||||
export const delete6Errors = {
|
||||
app_not_found: { status: 404, data: zAppNotFoundError },
|
||||
conversation_not_found: { status: 404, data: zConversationNotFoundError },
|
||||
}
|
||||
|
||||
export const get28Errors = { app_not_found: { status: 404, data: zAppNotFoundError } }
|
||||
export const get28Errors = {
|
||||
app_not_found: { status: 404, data: zAppNotFoundError },
|
||||
conversation_not_found: { status: 404, data: zConversationNotFoundError },
|
||||
}
|
||||
|
||||
export const get29Errors = { app_not_found: { status: 404, data: zAppNotFoundError } }
|
||||
|
||||
|
||||
+19
-6
@@ -19,6 +19,7 @@ import {
|
||||
zBadRequestError,
|
||||
zBindingFileDownloadUnavailableError,
|
||||
zCompletionRequestError,
|
||||
zConversationNotFoundError,
|
||||
zDraftWorkflowNotExistError,
|
||||
zDraftWorkflowNotSyncError,
|
||||
zForbiddenError,
|
||||
@@ -1735,7 +1736,10 @@ export const delete5 = oc
|
||||
})
|
||||
.input(z.object({ params: zDeleteAppsByAppIdChatConversationsByConversationIdPath }))
|
||||
.output(zDeleteAppsByAppIdChatConversationsByConversationIdResponse)
|
||||
.errors({ app_not_found: { status: 404, data: zAppNotFoundError } })
|
||||
.errors({
|
||||
app_not_found: { status: 404, data: zAppNotFoundError },
|
||||
conversation_not_found: { status: 404, data: zConversationNotFoundError },
|
||||
})
|
||||
|
||||
/**
|
||||
* Get chat conversation details
|
||||
@@ -1751,7 +1755,10 @@ export const get24 = oc
|
||||
})
|
||||
.input(z.object({ params: zGetAppsByAppIdChatConversationsByConversationIdPath }))
|
||||
.output(zGetAppsByAppIdChatConversationsByConversationIdResponse)
|
||||
.errors({ app_not_found: { status: 404, data: zAppNotFoundError } })
|
||||
.errors({
|
||||
app_not_found: { status: 404, data: zAppNotFoundError },
|
||||
conversation_not_found: { status: 404, data: zConversationNotFoundError },
|
||||
})
|
||||
|
||||
export const byConversationId = {
|
||||
delete: delete5,
|
||||
@@ -1759,11 +1766,11 @@ export const byConversationId = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat conversations with pagination, filtering and summary
|
||||
* Get chat conversations with pagination and filtering
|
||||
*/
|
||||
export const get25 = oc
|
||||
.route({
|
||||
description: 'Get chat conversations with pagination, filtering and summary',
|
||||
description: 'Get chat conversations with pagination and filtering',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdChatConversations',
|
||||
@@ -1886,7 +1893,10 @@ export const delete6 = oc
|
||||
})
|
||||
.input(z.object({ params: zDeleteAppsByAppIdCompletionConversationsByConversationIdPath }))
|
||||
.output(zDeleteAppsByAppIdCompletionConversationsByConversationIdResponse)
|
||||
.errors({ app_not_found: { status: 404, data: zAppNotFoundError } })
|
||||
.errors({
|
||||
app_not_found: { status: 404, data: zAppNotFoundError },
|
||||
conversation_not_found: { status: 404, data: zConversationNotFoundError },
|
||||
})
|
||||
|
||||
/**
|
||||
* Get completion conversation details with messages
|
||||
@@ -1902,7 +1912,10 @@ export const get28 = oc
|
||||
})
|
||||
.input(z.object({ params: zGetAppsByAppIdCompletionConversationsByConversationIdPath }))
|
||||
.output(zGetAppsByAppIdCompletionConversationsByConversationIdResponse)
|
||||
.errors({ app_not_found: { status: 404, data: zAppNotFoundError } })
|
||||
.errors({
|
||||
app_not_found: { status: 404, data: zAppNotFoundError },
|
||||
conversation_not_found: { status: 404, data: zConversationNotFoundError },
|
||||
})
|
||||
|
||||
export const byConversationId2 = {
|
||||
delete: delete6,
|
||||
|
||||
+19
-4
@@ -527,6 +527,11 @@ export type ConversationWithSummaryPagination = {
|
||||
total: number
|
||||
}
|
||||
|
||||
export type ConversationNotFoundError = ErrorResponse & {
|
||||
code: 'conversation_not_found'
|
||||
status: 404
|
||||
}
|
||||
|
||||
export type ConversationDetail = {
|
||||
admin_feedback_stats?: FeedbackStat | null
|
||||
annotated: boolean
|
||||
@@ -6112,8 +6117,11 @@ export type GetAppsByAppIdChatConversationsData = {
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdChatConversationsErrors = {
|
||||
400: ErrorResponse
|
||||
401: ErrorResponse
|
||||
403: ErrorResponse
|
||||
404: AppNotFoundError | ErrorResponse
|
||||
422: ErrorResponse
|
||||
default: ErrorResponse
|
||||
}
|
||||
|
||||
@@ -6138,8 +6146,9 @@ export type DeleteAppsByAppIdChatConversationsByConversationIdData = {
|
||||
}
|
||||
|
||||
export type DeleteAppsByAppIdChatConversationsByConversationIdErrors = {
|
||||
401: ErrorResponse
|
||||
403: ErrorResponse
|
||||
404: AppNotFoundError | ErrorResponse
|
||||
404: AppNotFoundError | ConversationNotFoundError | ErrorResponse
|
||||
default: ErrorResponse
|
||||
}
|
||||
|
||||
@@ -6164,8 +6173,9 @@ export type GetAppsByAppIdChatConversationsByConversationIdData = {
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdChatConversationsByConversationIdErrors = {
|
||||
401: ErrorResponse
|
||||
403: ErrorResponse
|
||||
404: AppNotFoundError | ErrorResponse
|
||||
404: AppNotFoundError | ConversationNotFoundError | ErrorResponse
|
||||
default: ErrorResponse
|
||||
}
|
||||
|
||||
@@ -6285,8 +6295,11 @@ export type GetAppsByAppIdCompletionConversationsData = {
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdCompletionConversationsErrors = {
|
||||
400: ErrorResponse
|
||||
401: ErrorResponse
|
||||
403: ErrorResponse
|
||||
404: AppNotFoundError | ErrorResponse
|
||||
422: ErrorResponse
|
||||
default: ErrorResponse
|
||||
}
|
||||
|
||||
@@ -6311,8 +6324,9 @@ export type DeleteAppsByAppIdCompletionConversationsByConversationIdData = {
|
||||
}
|
||||
|
||||
export type DeleteAppsByAppIdCompletionConversationsByConversationIdErrors = {
|
||||
401: ErrorResponse
|
||||
403: ErrorResponse
|
||||
404: AppNotFoundError | ErrorResponse
|
||||
404: AppNotFoundError | ConversationNotFoundError | ErrorResponse
|
||||
default: ErrorResponse
|
||||
}
|
||||
|
||||
@@ -6337,8 +6351,9 @@ export type GetAppsByAppIdCompletionConversationsByConversationIdData = {
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdCompletionConversationsByConversationIdErrors = {
|
||||
401: ErrorResponse
|
||||
403: ErrorResponse
|
||||
404: AppNotFoundError | ErrorResponse
|
||||
404: AppNotFoundError | ConversationNotFoundError | ErrorResponse
|
||||
default: ErrorResponse
|
||||
}
|
||||
|
||||
|
||||
@@ -554,6 +554,18 @@ export const zUnsupportedAudioTypeError = zErrorResponse.and(
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* ConversationNotFoundError
|
||||
*
|
||||
* The conversation was not found for this user and app.
|
||||
*/
|
||||
export const zConversationNotFoundError = zErrorResponse.and(
|
||||
z.object({
|
||||
code: z.literal('conversation_not_found'),
|
||||
status: z.literal(404),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* SuggestedQuestionsResponse
|
||||
*/
|
||||
|
||||
@@ -313,7 +313,7 @@ export const zConversationCursorNotFoundError = zErrorResponse.and(
|
||||
/**
|
||||
* ConversationNotFoundError
|
||||
*
|
||||
* The conversation was not found for this account and app.
|
||||
* The conversation was not found for this user and app.
|
||||
*/
|
||||
export const zConversationNotFoundError = zErrorResponse.and(
|
||||
z.object({
|
||||
|
||||
Reference in new issue
Block a user