mirror of https://github.com/langgenius/dify.git
fix: fix feedback like or dislike not display in logs (#28652)
This commit is contained in:
parent
0f521b26ae
commit
490b7ac43c
|
|
@ -369,6 +369,58 @@ class MessageSuggestedQuestionApi(Resource):
|
||||||
return {"data": questions}
|
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/<uuid:app_id>/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/<uuid:app_id>/messages/<uuid:message_id>")
|
@console_ns.route("/apps/<uuid:app_id>/messages/<uuid:message_id>")
|
||||||
class MessageApi(Resource):
|
class MessageApi(Resource):
|
||||||
@console_ns.doc("get_message")
|
@console_ns.doc("get_message")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"] == "👎"
|
||||||
|
|
@ -67,6 +67,10 @@ const Operation: FC<OperationProps> = ({
|
||||||
agent_thoughts,
|
agent_thoughts,
|
||||||
} = item
|
} = item
|
||||||
const [localFeedback, setLocalFeedback] = useState(config?.supportAnnotation ? adminFeedback : feedback)
|
const [localFeedback, setLocalFeedback] = useState(config?.supportAnnotation ? adminFeedback : feedback)
|
||||||
|
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
|
||||||
|
|
||||||
|
// Separate feedback types for display
|
||||||
|
const userFeedback = feedback
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
if (agent_thoughts?.length)
|
if (agent_thoughts?.length)
|
||||||
|
|
@ -81,6 +85,10 @@ const Operation: FC<OperationProps> = ({
|
||||||
|
|
||||||
await onFeedback?.(id, { rating, content })
|
await onFeedback?.(id, { rating, content })
|
||||||
setLocalFeedback({ rating })
|
setLocalFeedback({ rating })
|
||||||
|
|
||||||
|
// Update admin feedback state separately if annotation is supported
|
||||||
|
if (config?.supportAnnotation)
|
||||||
|
setAdminLocalFeedback(rating ? { rating } : undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleThumbsDown = () => {
|
const handleThumbsDown = () => {
|
||||||
|
|
@ -180,18 +188,53 @@ const Operation: FC<OperationProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isOpeningStatement && config?.supportFeedback && localFeedback?.rating && onFeedback && (
|
{!isOpeningStatement && config?.supportFeedback && onFeedback && (
|
||||||
<div className='ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm'>
|
<div className='ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm'>
|
||||||
{localFeedback?.rating === 'like' && (
|
{/* User Feedback Display */}
|
||||||
<ActionButton state={ActionButtonState.Active} onClick={() => handleFeedback(null)}>
|
{userFeedback?.rating && (
|
||||||
<RiThumbUpLine className='h-4 w-4' />
|
<div className='flex items-center'>
|
||||||
</ActionButton>
|
<span className='mr-1 text-xs text-text-tertiary'>User</span>
|
||||||
|
{userFeedback.rating === 'like' ? (
|
||||||
|
<ActionButton state={ActionButtonState.Active} title={userFeedback.content ? `User liked this response: ${userFeedback.content}` : 'User liked this response'}>
|
||||||
|
<RiThumbUpLine className='h-3 w-3' />
|
||||||
|
</ActionButton>
|
||||||
|
) : (
|
||||||
|
<ActionButton state={ActionButtonState.Destructive} title={userFeedback.content ? `User disliked this response: ${userFeedback.content}` : 'User disliked this response'}>
|
||||||
|
<RiThumbDownLine className='h-3 w-3' />
|
||||||
|
</ActionButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{localFeedback?.rating === 'dislike' && (
|
|
||||||
<ActionButton state={ActionButtonState.Destructive} onClick={() => handleFeedback(null)}>
|
{/* Admin Feedback Controls */}
|
||||||
<RiThumbDownLine className='h-4 w-4' />
|
{config?.supportAnnotation && (
|
||||||
</ActionButton>
|
<div className='flex items-center'>
|
||||||
|
{userFeedback?.rating && <div className='mx-1 h-3 w-[0.5px] bg-components-actionbar-border' />}
|
||||||
|
{!adminLocalFeedback?.rating ? (
|
||||||
|
<>
|
||||||
|
<ActionButton onClick={() => handleFeedback('like')}>
|
||||||
|
<RiThumbUpLine className='h-4 w-4' />
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton onClick={handleThumbsDown}>
|
||||||
|
<RiThumbDownLine className='h-4 w-4' />
|
||||||
|
</ActionButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{adminLocalFeedback.rating === 'like' ? (
|
||||||
|
<ActionButton state={ActionButtonState.Active} onClick={() => handleFeedback(null)}>
|
||||||
|
<RiThumbUpLine className='h-4 w-4' />
|
||||||
|
</ActionButton>
|
||||||
|
) : (
|
||||||
|
<ActionButton state={ActionButtonState.Destructive} onClick={() => handleFeedback(null)}>
|
||||||
|
<RiThumbDownLine className='h-4 w-4' />
|
||||||
|
</ActionButton>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue