diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 7fdf49c3fa..40e4020267 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -369,6 +369,58 @@ class MessageSuggestedQuestionApi(Resource): return {"data": questions} +# Shared parser for feedback export (used for both documentation and runtime parsing) +feedback_export_parser = ( + console_ns.parser() + .add_argument("from_source", type=str, choices=["user", "admin"], location="args", help="Filter by feedback source") + .add_argument("rating", type=str, choices=["like", "dislike"], location="args", help="Filter by rating") + .add_argument("has_comment", type=bool, location="args", help="Only include feedback with comments") + .add_argument("start_date", type=str, location="args", help="Start date (YYYY-MM-DD)") + .add_argument("end_date", type=str, location="args", help="End date (YYYY-MM-DD)") + .add_argument("format", type=str, choices=["csv", "json"], default="csv", location="args", help="Export format") +) + + +@console_ns.route("/apps//feedbacks/export") +class MessageFeedbackExportApi(Resource): + @console_ns.doc("export_feedbacks") + @console_ns.doc(description="Export user feedback data for Google Sheets") + @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.expect(feedback_export_parser) + @console_ns.response(200, "Feedback data exported successfully") + @console_ns.response(400, "Invalid parameters") + @console_ns.response(500, "Internal server error") + @get_app_model + @setup_required + @login_required + @account_initialization_required + def get(self, app_model): + args = feedback_export_parser.parse_args() + + # Import the service function + from services.feedback_service import FeedbackService + + try: + export_data = FeedbackService.export_feedbacks( + app_id=app_model.id, + from_source=args.get("from_source"), + rating=args.get("rating"), + has_comment=args.get("has_comment"), + start_date=args.get("start_date"), + end_date=args.get("end_date"), + format_type=args.get("format", "csv"), + ) + + return export_data + + except ValueError as e: + logger.exception("Parameter validation error in feedback export") + return {"error": f"Parameter validation error: {str(e)}"}, 400 + except Exception as e: + logger.exception("Error exporting feedback data") + raise InternalServerError(str(e)) + + @console_ns.route("/apps//messages/") class MessageApi(Resource): @console_ns.doc("get_message") diff --git a/api/services/feedback_service.py b/api/services/feedback_service.py new file mode 100644 index 0000000000..2bc965f6ba --- /dev/null +++ b/api/services/feedback_service.py @@ -0,0 +1,185 @@ +import csv +import io +import json +from datetime import datetime + +from flask import Response +from sqlalchemy import or_ + +from extensions.ext_database import db +from models.model import Account, App, Conversation, Message, MessageFeedback + + +class FeedbackService: + @staticmethod + def export_feedbacks( + app_id: str, + from_source: str | None = None, + rating: str | None = None, + has_comment: bool | None = None, + start_date: str | None = None, + end_date: str | None = None, + format_type: str = "csv", + ): + """ + Export feedback data with message details for analysis + + Args: + app_id: Application ID + from_source: Filter by feedback source ('user' or 'admin') + rating: Filter by rating ('like' or 'dislike') + has_comment: Only include feedback with comments + start_date: Start date filter (YYYY-MM-DD) + end_date: End date filter (YYYY-MM-DD) + format_type: Export format ('csv' or 'json') + """ + + # Validate format early to avoid hitting DB when unnecessary + fmt = (format_type or "csv").lower() + if fmt not in {"csv", "json"}: + raise ValueError(f"Unsupported format: {format_type}") + + # Build base query + query = ( + db.session.query(MessageFeedback, Message, Conversation, App, Account) + .join(Message, MessageFeedback.message_id == Message.id) + .join(Conversation, MessageFeedback.conversation_id == Conversation.id) + .join(App, MessageFeedback.app_id == App.id) + .outerjoin(Account, MessageFeedback.from_account_id == Account.id) + .where(MessageFeedback.app_id == app_id) + ) + + # Apply filters + if from_source: + query = query.filter(MessageFeedback.from_source == from_source) + + if rating: + query = query.filter(MessageFeedback.rating == rating) + + if has_comment is not None: + if has_comment: + query = query.filter(MessageFeedback.content.isnot(None), MessageFeedback.content != "") + else: + query = query.filter(or_(MessageFeedback.content.is_(None), MessageFeedback.content == "")) + + if start_date: + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + query = query.filter(MessageFeedback.created_at >= start_dt) + except ValueError: + raise ValueError(f"Invalid start_date format: {start_date}. Use YYYY-MM-DD") + + if end_date: + try: + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + query = query.filter(MessageFeedback.created_at <= end_dt) + except ValueError: + raise ValueError(f"Invalid end_date format: {end_date}. Use YYYY-MM-DD") + + # Order by creation date (newest first) + query = query.order_by(MessageFeedback.created_at.desc()) + + # Execute query + results = query.all() + + # Prepare data for export + export_data = [] + for feedback, message, conversation, app, account in results: + # Get the user query from the message + user_query = message.query or message.inputs.get("query", "") if message.inputs else "" + + # Format the feedback data + feedback_record = { + "feedback_id": str(feedback.id), + "app_name": app.name, + "app_id": str(app.id), + "conversation_id": str(conversation.id), + "conversation_name": conversation.name or "", + "message_id": str(message.id), + "user_query": user_query, + "ai_response": message.answer[:500] + "..." + if len(message.answer) > 500 + else message.answer, # Truncate long responses + "feedback_rating": "👍" if feedback.rating == "like" else "👎", + "feedback_rating_raw": feedback.rating, + "feedback_comment": feedback.content or "", + "feedback_source": feedback.from_source, + "feedback_date": feedback.created_at.strftime("%Y-%m-%d %H:%M:%S"), + "message_date": message.created_at.strftime("%Y-%m-%d %H:%M:%S"), + "from_account_name": account.name if account else "", + "from_end_user_id": str(feedback.from_end_user_id) if feedback.from_end_user_id else "", + "has_comment": "Yes" if feedback.content and feedback.content.strip() else "No", + } + export_data.append(feedback_record) + + # Export based on format + if fmt == "csv": + return FeedbackService._export_csv(export_data, app_id) + else: # fmt == "json" + return FeedbackService._export_json(export_data, app_id) + + @staticmethod + def _export_csv(data, app_id): + """Export data as CSV""" + if not data: + pass # allow empty CSV with headers only + + # Create CSV in memory + output = io.StringIO() + + # Define headers + headers = [ + "feedback_id", + "app_name", + "app_id", + "conversation_id", + "conversation_name", + "message_id", + "user_query", + "ai_response", + "feedback_rating", + "feedback_rating_raw", + "feedback_comment", + "feedback_source", + "feedback_date", + "message_date", + "from_account_name", + "from_end_user_id", + "has_comment", + ] + + writer = csv.DictWriter(output, fieldnames=headers) + writer.writeheader() + writer.writerows(data) + + # Create response without requiring app context + response = Response(output.getvalue(), mimetype="text/csv; charset=utf-8-sig") + response.headers["Content-Disposition"] = ( + f"attachment; filename=dify_feedback_export_{app_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + ) + + return response + + @staticmethod + def _export_json(data, app_id): + """Export data as JSON""" + response_data = { + "export_info": { + "app_id": app_id, + "export_date": datetime.now().isoformat(), + "total_records": len(data), + "data_source": "dify_feedback_export", + }, + "feedback_data": data, + } + + # Create response without requiring app context + response = Response( + json.dumps(response_data, ensure_ascii=False, indent=2), + mimetype="application/json; charset=utf-8", + ) + response.headers["Content-Disposition"] = ( + f"attachment; filename=dify_feedback_export_{app_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + ) + + return response diff --git a/api/tests/integration_tests/controllers/console/app/test_feedback_api_basic.py b/api/tests/integration_tests/controllers/console/app/test_feedback_api_basic.py new file mode 100644 index 0000000000..b164e4f887 --- /dev/null +++ b/api/tests/integration_tests/controllers/console/app/test_feedback_api_basic.py @@ -0,0 +1,106 @@ +"""Basic integration tests for Feedback API endpoints.""" + +import uuid + +from flask.testing import FlaskClient + + +class TestFeedbackApiBasic: + """Basic tests for feedback API endpoints.""" + + def test_feedback_export_endpoint_exists(self, test_client: FlaskClient, auth_header): + """Test that feedback export endpoint exists and handles basic requests.""" + + app_id = str(uuid.uuid4()) + + # Test endpoint exists (even if it fails, it should return 500 or 403, not 404) + response = test_client.get( + f"/console/api/apps/{app_id}/feedbacks/export", headers=auth_header, query_string={"format": "csv"} + ) + + # Should not return 404 (endpoint exists) + assert response.status_code != 404 + + # Should return authentication or permission error + assert response.status_code in [401, 403, 500] # 500 if app doesn't exist, 403 if no permission + + def test_feedback_summary_endpoint_exists(self, test_client: FlaskClient, auth_header): + """Test that feedback summary endpoint exists and handles basic requests.""" + + app_id = str(uuid.uuid4()) + + # Test endpoint exists + response = test_client.get(f"/console/api/apps/{app_id}/feedbacks/summary", headers=auth_header) + + # Should not return 404 (endpoint exists) + assert response.status_code != 404 + + # Should return authentication or permission error + assert response.status_code in [401, 403, 500] + + def test_feedback_export_invalid_format(self, test_client: FlaskClient, auth_header): + """Test feedback export endpoint with invalid format parameter.""" + + app_id = str(uuid.uuid4()) + + # Test with invalid format + response = test_client.get( + f"/console/api/apps/{app_id}/feedbacks/export", + headers=auth_header, + query_string={"format": "invalid_format"}, + ) + + # Should not return 404 + assert response.status_code != 404 + + def test_feedback_export_with_filters(self, test_client: FlaskClient, auth_header): + """Test feedback export endpoint with various filter parameters.""" + + app_id = str(uuid.uuid4()) + + # Test with various filter combinations + filter_params = [ + {"from_source": "user"}, + {"rating": "like"}, + {"has_comment": True}, + {"start_date": "2024-01-01"}, + {"end_date": "2024-12-31"}, + {"format": "json"}, + { + "from_source": "admin", + "rating": "dislike", + "has_comment": True, + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "format": "csv", + }, + ] + + for params in filter_params: + response = test_client.get( + f"/console/api/apps/{app_id}/feedbacks/export", headers=auth_header, query_string=params + ) + + # Should not return 404 + assert response.status_code != 404 + + def test_feedback_export_invalid_dates(self, test_client: FlaskClient, auth_header): + """Test feedback export endpoint with invalid date formats.""" + + app_id = str(uuid.uuid4()) + + # Test with invalid date formats + invalid_dates = [ + {"start_date": "invalid-date"}, + {"end_date": "not-a-date"}, + {"start_date": "2024-13-01"}, # Invalid month + {"end_date": "2024-12-32"}, # Invalid day + ] + + for params in invalid_dates: + response = test_client.get( + f"/console/api/apps/{app_id}/feedbacks/export", headers=auth_header, query_string=params + ) + + # Should not return 404 + assert response.status_code != 404 diff --git a/api/tests/integration_tests/controllers/console/app/test_feedback_export_api.py b/api/tests/integration_tests/controllers/console/app/test_feedback_export_api.py new file mode 100644 index 0000000000..0f8b42e98b --- /dev/null +++ b/api/tests/integration_tests/controllers/console/app/test_feedback_export_api.py @@ -0,0 +1,334 @@ +"""Integration tests for Feedback Export API endpoints.""" + +import json +import uuid +from datetime import datetime +from types import SimpleNamespace +from unittest import mock + +import pytest +from flask.testing import FlaskClient + +from controllers.console.app import message as message_api +from controllers.console.app import wraps +from libs.datetime_utils import naive_utc_now +from models import App, Tenant +from models.account import Account, TenantAccountJoin, TenantAccountRole +from models.model import AppMode, MessageFeedback +from services.feedback_service import FeedbackService + + +class TestFeedbackExportApi: + """Test feedback export API endpoints.""" + + @pytest.fixture + def mock_app_model(self): + """Create a mock App model for testing.""" + app = App() + app.id = str(uuid.uuid4()) + app.mode = AppMode.CHAT + app.tenant_id = str(uuid.uuid4()) + app.status = "normal" + app.name = "Test App" + return app + + @pytest.fixture + def mock_account(self, monkeypatch: pytest.MonkeyPatch): + """Create a mock Account for testing.""" + account = Account( + name="Test User", + email="test@example.com", + ) + account.last_active_at = naive_utc_now() + account.created_at = naive_utc_now() + account.updated_at = naive_utc_now() + account.id = str(uuid.uuid4()) + + # Create mock tenant + tenant = Tenant(name="Test Tenant") + tenant.id = str(uuid.uuid4()) + + mock_session_instance = mock.Mock() + + mock_tenant_join = TenantAccountJoin(role=TenantAccountRole.OWNER) + monkeypatch.setattr(mock_session_instance, "scalar", mock.Mock(return_value=mock_tenant_join)) + + mock_scalars_result = mock.Mock() + mock_scalars_result.one.return_value = tenant + monkeypatch.setattr(mock_session_instance, "scalars", mock.Mock(return_value=mock_scalars_result)) + + mock_session_context = mock.Mock() + mock_session_context.__enter__.return_value = mock_session_instance + monkeypatch.setattr("models.account.Session", lambda _, expire_on_commit: mock_session_context) + + account.current_tenant = tenant + return account + + @pytest.fixture + def sample_feedback_data(self): + """Create sample feedback data for testing.""" + app_id = str(uuid.uuid4()) + conversation_id = str(uuid.uuid4()) + message_id = str(uuid.uuid4()) + + # Mock feedback data + user_feedback = MessageFeedback( + id=str(uuid.uuid4()), + app_id=app_id, + conversation_id=conversation_id, + message_id=message_id, + rating="like", + from_source="user", + content=None, + from_end_user_id=str(uuid.uuid4()), + from_account_id=None, + created_at=naive_utc_now(), + ) + + admin_feedback = MessageFeedback( + id=str(uuid.uuid4()), + app_id=app_id, + conversation_id=conversation_id, + message_id=message_id, + rating="dislike", + from_source="admin", + content="The response was not helpful", + from_end_user_id=None, + from_account_id=str(uuid.uuid4()), + created_at=naive_utc_now(), + ) + + # Mock message and conversation + mock_message = SimpleNamespace( + id=message_id, + conversation_id=conversation_id, + query="What is the weather today?", + answer="It's sunny and 25 degrees outside.", + inputs={"query": "What is the weather today?"}, + created_at=naive_utc_now(), + ) + + mock_conversation = SimpleNamespace(id=conversation_id, name="Weather Conversation", app_id=app_id) + + mock_app = SimpleNamespace(id=app_id, name="Weather App") + + return { + "user_feedback": user_feedback, + "admin_feedback": admin_feedback, + "message": mock_message, + "conversation": mock_conversation, + "app": mock_app, + } + + @pytest.mark.parametrize( + ("role", "status"), + [ + (TenantAccountRole.OWNER, 200), + (TenantAccountRole.ADMIN, 200), + (TenantAccountRole.EDITOR, 200), + (TenantAccountRole.NORMAL, 403), + (TenantAccountRole.DATASET_OPERATOR, 403), + ], + ) + def test_feedback_export_permissions( + self, + test_client: FlaskClient, + auth_header, + monkeypatch, + mock_app_model, + mock_account, + role: TenantAccountRole, + status: int, + ): + """Test feedback export endpoint permissions.""" + + # Setup mocks + mock_load_app_model = mock.Mock(return_value=mock_app_model) + monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model) + + mock_export_feedbacks = mock.Mock(return_value="mock csv response") + monkeypatch.setattr(FeedbackService, "export_feedbacks", mock_export_feedbacks) + + monkeypatch.setattr(message_api, "current_user", mock_account) + + # Set user role + mock_account.role = role + + response = test_client.get( + f"/console/api/apps/{mock_app_model.id}/feedbacks/export", + headers=auth_header, + query_string={"format": "csv"}, + ) + + assert response.status_code == status + + if status == 200: + mock_export_feedbacks.assert_called_once() + + def test_feedback_export_csv_format( + self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account, sample_feedback_data + ): + """Test feedback export in CSV format.""" + + # Setup mocks + mock_load_app_model = mock.Mock(return_value=mock_app_model) + monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model) + + # Create mock CSV response + mock_csv_content = ( + "feedback_id,app_name,conversation_id,user_query,ai_response,feedback_rating,feedback_comment\n" + ) + mock_csv_content += f"{sample_feedback_data['user_feedback'].id},{sample_feedback_data['app'].name}," + mock_csv_content += f"{sample_feedback_data['conversation'].id},{sample_feedback_data['message'].query}," + mock_csv_content += f"{sample_feedback_data['message'].answer},👍,\n" + + mock_response = mock.Mock() + mock_response.headers = {"Content-Type": "text/csv; charset=utf-8-sig"} + mock_response.data = mock_csv_content.encode("utf-8") + + mock_export_feedbacks = mock.Mock(return_value=mock_response) + monkeypatch.setattr(FeedbackService, "export_feedbacks", mock_export_feedbacks) + + monkeypatch.setattr(message_api, "current_user", mock_account) + + response = test_client.get( + f"/console/api/apps/{mock_app_model.id}/feedbacks/export", + headers=auth_header, + query_string={"format": "csv", "from_source": "user"}, + ) + + assert response.status_code == 200 + assert "text/csv" in response.content_type + + def test_feedback_export_json_format( + self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account, sample_feedback_data + ): + """Test feedback export in JSON format.""" + + # Setup mocks + mock_load_app_model = mock.Mock(return_value=mock_app_model) + monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model) + + mock_json_response = { + "export_info": { + "app_id": mock_app_model.id, + "export_date": datetime.now().isoformat(), + "total_records": 2, + "data_source": "dify_feedback_export", + }, + "feedback_data": [ + { + "feedback_id": sample_feedback_data["user_feedback"].id, + "feedback_rating": "👍", + "feedback_rating_raw": "like", + "feedback_comment": "", + } + ], + } + + mock_response = mock.Mock() + mock_response.headers = {"Content-Type": "application/json; charset=utf-8"} + mock_response.data = json.dumps(mock_json_response).encode("utf-8") + + mock_export_feedbacks = mock.Mock(return_value=mock_response) + monkeypatch.setattr(FeedbackService, "export_feedbacks", mock_export_feedbacks) + + monkeypatch.setattr(message_api, "current_user", mock_account) + + response = test_client.get( + f"/console/api/apps/{mock_app_model.id}/feedbacks/export", + headers=auth_header, + query_string={"format": "json"}, + ) + + assert response.status_code == 200 + assert "application/json" in response.content_type + + def test_feedback_export_with_filters( + self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account + ): + """Test feedback export with various filters.""" + + # Setup mocks + mock_load_app_model = mock.Mock(return_value=mock_app_model) + monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model) + + mock_export_feedbacks = mock.Mock(return_value="mock filtered response") + monkeypatch.setattr(FeedbackService, "export_feedbacks", mock_export_feedbacks) + + monkeypatch.setattr(message_api, "current_user", mock_account) + + # Test with multiple filters + response = test_client.get( + f"/console/api/apps/{mock_app_model.id}/feedbacks/export", + headers=auth_header, + query_string={ + "from_source": "user", + "rating": "dislike", + "has_comment": True, + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "format": "csv", + }, + ) + + assert response.status_code == 200 + + # Verify service was called with correct parameters + mock_export_feedbacks.assert_called_once_with( + app_id=mock_app_model.id, + from_source="user", + rating="dislike", + has_comment=True, + start_date="2024-01-01", + end_date="2024-12-31", + format_type="csv", + ) + + def test_feedback_export_invalid_date_format( + self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account + ): + """Test feedback export with invalid date format.""" + + # Setup mocks + mock_load_app_model = mock.Mock(return_value=mock_app_model) + monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model) + + # Mock the service to raise ValueError for invalid date + mock_export_feedbacks = mock.Mock(side_effect=ValueError("Invalid date format")) + monkeypatch.setattr(FeedbackService, "export_feedbacks", mock_export_feedbacks) + + monkeypatch.setattr(message_api, "current_user", mock_account) + + response = test_client.get( + f"/console/api/apps/{mock_app_model.id}/feedbacks/export", + headers=auth_header, + query_string={"start_date": "invalid-date", "format": "csv"}, + ) + + assert response.status_code == 400 + response_json = response.get_json() + assert "Parameter validation error" in response_json["error"] + + def test_feedback_export_server_error( + self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account + ): + """Test feedback export with server error.""" + + # Setup mocks + mock_load_app_model = mock.Mock(return_value=mock_app_model) + monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model) + + # Mock the service to raise an exception + mock_export_feedbacks = mock.Mock(side_effect=Exception("Database connection failed")) + monkeypatch.setattr(FeedbackService, "export_feedbacks", mock_export_feedbacks) + + monkeypatch.setattr(message_api, "current_user", mock_account) + + response = test_client.get( + f"/console/api/apps/{mock_app_model.id}/feedbacks/export", + headers=auth_header, + query_string={"format": "csv"}, + ) + + assert response.status_code == 500 diff --git a/api/tests/test_containers_integration_tests/services/test_feedback_service.py b/api/tests/test_containers_integration_tests/services/test_feedback_service.py new file mode 100644 index 0000000000..60919dff0d --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/test_feedback_service.py @@ -0,0 +1,386 @@ +"""Unit tests for FeedbackService.""" + +import json +from datetime import datetime +from types import SimpleNamespace +from unittest import mock + +import pytest + +from extensions.ext_database import db +from models.model import App, Conversation, Message +from services.feedback_service import FeedbackService + + +class TestFeedbackService: + """Test FeedbackService methods.""" + + @pytest.fixture + def mock_db_session(self, monkeypatch): + """Mock database session.""" + mock_session = mock.Mock() + monkeypatch.setattr(db, "session", mock_session) + return mock_session + + @pytest.fixture + def sample_data(self): + """Create sample data for testing.""" + app_id = "test-app-id" + + # Create mock models + app = App(id=app_id, name="Test App") + + conversation = Conversation(id="test-conversation-id", app_id=app_id, name="Test Conversation") + + message = Message( + id="test-message-id", + conversation_id="test-conversation-id", + query="What is AI?", + answer="AI is artificial intelligence.", + inputs={"query": "What is AI?"}, + created_at=datetime(2024, 1, 1, 10, 0, 0), + ) + + # Use SimpleNamespace to avoid ORM model constructor issues + user_feedback = SimpleNamespace( + id="user-feedback-id", + app_id=app_id, + conversation_id="test-conversation-id", + message_id="test-message-id", + rating="like", + from_source="user", + content="Great answer!", + from_end_user_id="user-123", + from_account_id=None, + from_account=None, # Mock account object + created_at=datetime(2024, 1, 1, 10, 5, 0), + ) + + admin_feedback = SimpleNamespace( + id="admin-feedback-id", + app_id=app_id, + conversation_id="test-conversation-id", + message_id="test-message-id", + rating="dislike", + from_source="admin", + content="Could be more detailed", + from_end_user_id=None, + from_account_id="admin-456", + from_account=SimpleNamespace(name="Admin User"), # Mock account object + created_at=datetime(2024, 1, 1, 10, 10, 0), + ) + + return { + "app": app, + "conversation": conversation, + "message": message, + "user_feedback": user_feedback, + "admin_feedback": admin_feedback, + } + + def test_export_feedbacks_csv_format(self, mock_db_session, sample_data): + """Test exporting feedback data in CSV format.""" + + # Setup mock query result + mock_query = mock.Mock() + mock_query.join.return_value = mock_query + mock_query.outerjoin.return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = [ + ( + sample_data["user_feedback"], + sample_data["message"], + sample_data["conversation"], + sample_data["app"], + sample_data["user_feedback"].from_account, + ) + ] + + mock_db_session.query.return_value = mock_query + + # Test CSV export + result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv") + + # Verify response structure + assert hasattr(result, "headers") + assert "text/csv" in result.headers["Content-Type"] + assert "attachment" in result.headers["Content-Disposition"] + + # Check CSV content + csv_content = result.get_data(as_text=True) + # Verify essential headers exist (order may include additional columns) + assert "feedback_id" in csv_content + assert "app_name" in csv_content + assert "conversation_id" in csv_content + assert sample_data["app"].name in csv_content + assert sample_data["message"].query in csv_content + + def test_export_feedbacks_json_format(self, mock_db_session, sample_data): + """Test exporting feedback data in JSON format.""" + + # Setup mock query result + mock_query = mock.Mock() + mock_query.join.return_value = mock_query + mock_query.outerjoin.return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = [ + ( + sample_data["admin_feedback"], + sample_data["message"], + sample_data["conversation"], + sample_data["app"], + sample_data["admin_feedback"].from_account, + ) + ] + + mock_db_session.query.return_value = mock_query + + # Test JSON export + result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json") + + # Verify response structure + assert hasattr(result, "headers") + assert "application/json" in result.headers["Content-Type"] + assert "attachment" in result.headers["Content-Disposition"] + + # Check JSON content + json_content = json.loads(result.get_data(as_text=True)) + assert "export_info" in json_content + assert "feedback_data" in json_content + assert json_content["export_info"]["app_id"] == sample_data["app"].id + assert json_content["export_info"]["total_records"] == 1 + + def test_export_feedbacks_with_filters(self, mock_db_session, sample_data): + """Test exporting feedback with various filters.""" + + # Setup mock query result + mock_query = mock.Mock() + mock_query.join.return_value = mock_query + mock_query.outerjoin.return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = [ + ( + sample_data["admin_feedback"], + sample_data["message"], + sample_data["conversation"], + sample_data["app"], + sample_data["admin_feedback"].from_account, + ) + ] + + mock_db_session.query.return_value = mock_query + + # Test with filters + result = FeedbackService.export_feedbacks( + app_id=sample_data["app"].id, + from_source="admin", + rating="dislike", + has_comment=True, + start_date="2024-01-01", + end_date="2024-12-31", + format_type="csv", + ) + + # Verify filters were applied + assert mock_query.filter.called + filter_calls = mock_query.filter.call_args_list + # At least three filter invocations are expected (source, rating, comment) + assert len(filter_calls) >= 3 + + def test_export_feedbacks_no_data(self, mock_db_session, sample_data): + """Test exporting feedback when no data exists.""" + + # Setup mock query result with no data + mock_query = mock.Mock() + mock_query.join.return_value = mock_query + mock_query.outerjoin.return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = [] + + mock_db_session.query.return_value = mock_query + + result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv") + + # Should return an empty CSV with headers only + assert hasattr(result, "headers") + assert "text/csv" in result.headers["Content-Type"] + csv_content = result.get_data(as_text=True) + # Headers should exist (order can include additional columns) + assert "feedback_id" in csv_content + assert "app_name" in csv_content + assert "conversation_id" in csv_content + # No data rows expected + assert len([line for line in csv_content.strip().splitlines() if line.strip()]) == 1 + + def test_export_feedbacks_invalid_date_format(self, mock_db_session, sample_data): + """Test exporting feedback with invalid date format.""" + + # Test with invalid start_date + with pytest.raises(ValueError, match="Invalid start_date format"): + FeedbackService.export_feedbacks(app_id=sample_data["app"].id, start_date="invalid-date-format") + + # Test with invalid end_date + with pytest.raises(ValueError, match="Invalid end_date format"): + FeedbackService.export_feedbacks(app_id=sample_data["app"].id, end_date="invalid-date-format") + + def test_export_feedbacks_invalid_format(self, mock_db_session, sample_data): + """Test exporting feedback with unsupported format.""" + + with pytest.raises(ValueError, match="Unsupported format"): + FeedbackService.export_feedbacks( + app_id=sample_data["app"].id, + format_type="xml", # Unsupported format + ) + + def test_export_feedbacks_long_response_truncation(self, mock_db_session, sample_data): + """Test that long AI responses are truncated in export.""" + + # Create message with long response + long_message = Message( + id="long-message-id", + conversation_id="test-conversation-id", + query="What is AI?", + answer="A" * 600, # 600 character response + inputs={"query": "What is AI?"}, + created_at=datetime(2024, 1, 1, 10, 0, 0), + ) + + # Setup mock query result + mock_query = mock.Mock() + mock_query.join.return_value = mock_query + mock_query.outerjoin.return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = [ + ( + sample_data["user_feedback"], + long_message, + sample_data["conversation"], + sample_data["app"], + sample_data["user_feedback"].from_account, + ) + ] + + mock_db_session.query.return_value = mock_query + + # Test export + result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json") + + # Check JSON content + json_content = json.loads(result.get_data(as_text=True)) + exported_answer = json_content["feedback_data"][0]["ai_response"] + + # Should be truncated with ellipsis + assert len(exported_answer) <= 503 # 500 + "..." + assert exported_answer.endswith("...") + assert len(exported_answer) > 500 # Should be close to limit + + def test_export_feedbacks_unicode_content(self, mock_db_session, sample_data): + """Test exporting feedback with unicode content (Chinese characters).""" + + # Create feedback with Chinese content (use SimpleNamespace to avoid ORM constructor constraints) + chinese_feedback = SimpleNamespace( + id="chinese-feedback-id", + app_id=sample_data["app"].id, + conversation_id="test-conversation-id", + message_id="test-message-id", + rating="dislike", + from_source="user", + content="回答不够详细,需要更多信息", + from_end_user_id="user-123", + from_account_id=None, + created_at=datetime(2024, 1, 1, 10, 5, 0), + ) + + # Create Chinese message + chinese_message = Message( + id="chinese-message-id", + conversation_id="test-conversation-id", + query="什么是人工智能?", + answer="人工智能是模拟人类智能的技术。", + inputs={"query": "什么是人工智能?"}, + created_at=datetime(2024, 1, 1, 10, 0, 0), + ) + + # Setup mock query result + mock_query = mock.Mock() + mock_query.join.return_value = mock_query + mock_query.outerjoin.return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = [ + ( + chinese_feedback, + chinese_message, + sample_data["conversation"], + sample_data["app"], + None, # No account for user feedback + ) + ] + + mock_db_session.query.return_value = mock_query + + # Test export + result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv") + + # Check that unicode content is preserved + csv_content = result.get_data(as_text=True) + assert "什么是人工智能?" in csv_content + assert "回答不够详细,需要更多信息" in csv_content + assert "人工智能是模拟人类智能的技术" in csv_content + + def test_export_feedbacks_emoji_ratings(self, mock_db_session, sample_data): + """Test that rating emojis are properly formatted in export.""" + + # Setup mock query result with both like and dislike feedback + mock_query = mock.Mock() + mock_query.join.return_value = mock_query + mock_query.outerjoin.return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = [ + ( + sample_data["user_feedback"], + sample_data["message"], + sample_data["conversation"], + sample_data["app"], + sample_data["user_feedback"].from_account, + ), + ( + sample_data["admin_feedback"], + sample_data["message"], + sample_data["conversation"], + sample_data["app"], + sample_data["admin_feedback"].from_account, + ), + ] + + mock_db_session.query.return_value = mock_query + + # Test export + result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json") + + # Check JSON content for emoji ratings + json_content = json.loads(result.get_data(as_text=True)) + feedback_data = json_content["feedback_data"] + + # Should have both feedback records + assert len(feedback_data) == 2 + + # Check that emojis are properly set + like_feedback = next(f for f in feedback_data if f["feedback_rating_raw"] == "like") + dislike_feedback = next(f for f in feedback_data if f["feedback_rating_raw"] == "dislike") + + assert like_feedback["feedback_rating"] == "👍" + assert dislike_feedback["feedback_rating"] == "👎" diff --git a/web/app/components/base/chat/chat/answer/operation.tsx b/web/app/components/base/chat/chat/answer/operation.tsx index 7ffb21c6d8..6868d76c73 100644 --- a/web/app/components/base/chat/chat/answer/operation.tsx +++ b/web/app/components/base/chat/chat/answer/operation.tsx @@ -67,6 +67,10 @@ const Operation: FC = ({ agent_thoughts, } = item const [localFeedback, setLocalFeedback] = useState(config?.supportAnnotation ? adminFeedback : feedback) + const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback) + + // Separate feedback types for display + const userFeedback = feedback const content = useMemo(() => { if (agent_thoughts?.length) @@ -81,6 +85,10 @@ const Operation: FC = ({ await onFeedback?.(id, { rating, content }) setLocalFeedback({ rating }) + + // Update admin feedback state separately if annotation is supported + if (config?.supportAnnotation) + setAdminLocalFeedback(rating ? { rating } : undefined) } const handleThumbsDown = () => { @@ -180,18 +188,53 @@ const Operation: FC = ({ )} )} - {!isOpeningStatement && config?.supportFeedback && localFeedback?.rating && onFeedback && ( + {!isOpeningStatement && config?.supportFeedback && onFeedback && (
- {localFeedback?.rating === 'like' && ( - handleFeedback(null)}> - - + {/* User Feedback Display */} + {userFeedback?.rating && ( +
+ User + {userFeedback.rating === 'like' ? ( + + + + ) : ( + + + + )} +
)} - {localFeedback?.rating === 'dislike' && ( - handleFeedback(null)}> - - + + {/* Admin Feedback Controls */} + {config?.supportAnnotation && ( +
+ {userFeedback?.rating &&
} + {!adminLocalFeedback?.rating ? ( + <> + handleFeedback('like')}> + + + + + + + ) : ( + <> + {adminLocalFeedback.rating === 'like' ? ( + handleFeedback(null)}> + + + ) : ( + handleFeedback(null)}> + + + )} + + )} +
)} +
)}