Files
dify/api/services/message_feedback_service.py
T

141 lines
4.7 KiB
Python

"""Message feedback shared by user-facing and Console management endpoints."""
import logging
from dataclasses import dataclass
from typing import Protocol
from models.enums import FeedbackRating
from services.entities.message_entities import MessageAccount, MessageActor
from services.installed_app_access_service import InstalledAppRef
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class MessageFeedbackRecord:
id: str
app_id: str
conversation_id: str
message_id: str
rating: str
content: str | None
from_source: str
from_end_user_id: str | None
from_account_id: str | None
created_at: str
updated_at: str
class MessageFeedbackStore(Protocol):
def set_feedback(
self,
*,
app_id: str,
app_owner_tenant_id: str,
actor: MessageActor,
message_id: str,
rating: FeedbackRating | None,
content: str | None,
app_wide: bool,
installed_app: InstalledAppRef | None,
) -> MessageFeedbackRecord | None:
"""Commit a rating and return detached data, or revoke it and return None."""
...
def get_feedbacks(
self, *, app_id: str, app_owner_tenant_id: str, page: int, limit: int
) -> list[MessageFeedbackRecord]: ...
class MessageFeedbackService:
def __init__(self, *, repository: MessageFeedbackStore) -> None:
self._repository: MessageFeedbackStore = repository
def set_feedback(
self,
*,
app_id: str,
app_owner_tenant_id: str,
actor: MessageActor,
message_id: str,
rating: FeedbackRating | None,
content: str | None,
installed_app: InstalledAppRef | None = None,
) -> None:
"""Rate the actor's own message; None revokes existing feedback.
Explore supplies its installation to recheck admission in the write
transaction. Other entrypoints have no installation (None).
Content may be absent (None) or explicitly empty, and is saved as given.
"""
feedback = self._repository.set_feedback(
app_id=app_id,
app_owner_tenant_id=app_owner_tenant_id,
actor=actor,
message_id=message_id,
rating=rating,
content=content,
app_wide=False,
installed_app=installed_app,
)
if feedback is None:
return
# The repository has committed and closed its session. Telemetry failure
# must not turn a successful feedback write into a failed HTTP request.
try:
from core.telemetry import FeedbackCreatedEvent, TelemetryContext, emit
emit(
FeedbackCreatedEvent(
context=TelemetryContext(tenant_id=app_owner_tenant_id),
payload={
"message_id": feedback.message_id,
"app_id": feedback.app_id,
"conversation_id": feedback.conversation_id,
"from_end_user_id": None if isinstance(actor, MessageAccount) else actor.end_user_id,
"from_account_id": actor.account_id if isinstance(actor, MessageAccount) else None,
"rating": feedback.rating,
"from_source": feedback.from_source,
"content": feedback.content,
},
)
)
except Exception:
logger.warning("Failed to emit feedback telemetry for message %s", message_id, exc_info=True)
def set_admin_feedback(
self,
*,
app_id: str,
app_owner_tenant_id: str,
account_id: str,
message_id: str,
rating: FeedbackRating | None,
content: str | None,
) -> None:
"""Rate any message in an admitted Console app, without user telemetry.
None rating revokes existing admin feedback. None content means no comment.
Console admission owns workspace/RBAC checks; being a MessageAccount alone
does not grant this broader message access.
"""
self._repository.set_feedback(
app_id=app_id,
app_owner_tenant_id=app_owner_tenant_id,
actor=MessageAccount(account_id=account_id),
message_id=message_id,
rating=rating,
content=content,
app_wide=True,
installed_app=None,
)
def get_feedbacks(
self, *, app_id: str, app_owner_tenant_id: str, page: int, limit: int
) -> list[MessageFeedbackRecord]:
"""List app feedback newest first, using one-based pages."""
return self._repository.get_feedbacks(
app_id=app_id, app_owner_tenant_id=app_owner_tenant_id, page=page, limit=limit
)