feat(moderation): add user-facing reasons for rejected or failed content
Prompt AI models to provide short explanations for rejected content Store reasons in database and broadcast via WebSocket Display moderation details in UI for authors and admins
This commit is contained in:
@@ -181,6 +181,16 @@ function canRetryModeration(comment: EntityDiscussionComment) {
|
||||
return !comment.deleted && comment.moderationStatus !== 'approved' && comment.moderationStatus !== 'reviewing' && canSeeModeration(comment);
|
||||
}
|
||||
|
||||
function moderationReasonVisible(comment: EntityDiscussionComment) {
|
||||
return (
|
||||
!comment.deleted &&
|
||||
canSeeModeration(comment) &&
|
||||
(comment.moderationStatus === 'rejected' || comment.moderationStatus === 'failed') &&
|
||||
comment.moderationReason !== null &&
|
||||
comment.moderationReason.trim() !== ''
|
||||
);
|
||||
}
|
||||
|
||||
function moderationLabel(status: AiModerationStatus) {
|
||||
const labels: Record<AiModerationStatus, string> = {
|
||||
unreviewed: t('discussion.moderationUnreviewed'),
|
||||
@@ -299,6 +309,7 @@ async function retryModeration(comment: EntityDiscussionComment) {
|
||||
const updated = await api.retryEntityDiscussionModeration(comment.id);
|
||||
comment.moderationStatus = updated.moderationStatus;
|
||||
comment.moderationLanguageCode = updated.moderationLanguageCode;
|
||||
comment.moderationReason = updated.moderationReason;
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.moderationRetryFailed'));
|
||||
} finally {
|
||||
@@ -310,16 +321,18 @@ function updateDiscussionCommentModeration(
|
||||
items: EntityDiscussionComment[],
|
||||
commentId: number,
|
||||
status: AiModerationStatus,
|
||||
languageCode: string | null
|
||||
languageCode: string | null,
|
||||
reason: string | null
|
||||
): boolean {
|
||||
for (const comment of items) {
|
||||
if (comment.id === commentId) {
|
||||
comment.moderationStatus = status;
|
||||
comment.moderationLanguageCode = languageCode;
|
||||
comment.moderationReason = reason;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (updateDiscussionCommentModeration(comment.replies, commentId, status, languageCode)) {
|
||||
if (updateDiscussionCommentModeration(comment.replies, commentId, status, languageCode, reason)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -336,7 +349,7 @@ function handleModerationUpdate(event: Event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { target, moderationStatus, moderationLanguageCode } = event.detail;
|
||||
const { target, moderationStatus, moderationLanguageCode, moderationReason } = event.detail;
|
||||
if (
|
||||
target.type !== 'discussion-comment' ||
|
||||
target.discussionCommentId === null ||
|
||||
@@ -350,7 +363,8 @@ function handleModerationUpdate(event: Event) {
|
||||
comments.value,
|
||||
target.discussionCommentId,
|
||||
moderationStatus,
|
||||
moderationLanguageCode
|
||||
moderationLanguageCode,
|
||||
moderationReason
|
||||
);
|
||||
if (updated) {
|
||||
comments.value = [...comments.value];
|
||||
@@ -508,6 +522,10 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!comment.deleted" class="entity-discussion-comment__body">{{ comment.body }}</p>
|
||||
<p v-if="moderationReasonVisible(comment)" class="life-moderation-detail life-moderation-detail--comment">
|
||||
<strong>{{ t('discussion.moderationReason') }}</strong>
|
||||
<span>{{ comment.moderationReason }}</span>
|
||||
</p>
|
||||
|
||||
<div v-if="!comment.deleted" class="entity-discussion-comment__actions">
|
||||
<button
|
||||
@@ -602,6 +620,10 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!reply.deleted" class="entity-discussion-comment__body">{{ reply.body }}</p>
|
||||
<p v-if="moderationReasonVisible(reply)" class="life-moderation-detail life-moderation-detail--comment">
|
||||
<strong>{{ t('discussion.moderationReason') }}</strong>
|
||||
<span>{{ reply.moderationReason }}</span>
|
||||
</p>
|
||||
<div v-if="canManageComment(reply) || canRetryModeration(reply)" class="entity-discussion-comment__actions">
|
||||
<button
|
||||
v-if="canRetryModeration(reply)"
|
||||
|
||||
@@ -296,6 +296,15 @@ function notificationText(notification: NotificationItem) {
|
||||
return t('notifications.moderationFailed', { target });
|
||||
}
|
||||
|
||||
function notificationReasonVisible(notification: NotificationItem) {
|
||||
return (
|
||||
notification.type === 'moderation_result' &&
|
||||
(notification.moderationStatus === 'rejected' || notification.moderationStatus === 'failed') &&
|
||||
notification.moderationReason !== null &&
|
||||
notification.moderationReason.trim() !== ''
|
||||
);
|
||||
}
|
||||
|
||||
function notificationIcon(notification: NotificationItem) {
|
||||
if (notification.type === 'life_post_comment') {
|
||||
return iconComment;
|
||||
@@ -409,6 +418,9 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
<span class="notification-item__copy">
|
||||
<strong>{{ notificationText(notification) }}</strong>
|
||||
<span v-if="notificationReasonVisible(notification)" class="notification-item__detail">
|
||||
{{ notification.moderationReason }}
|
||||
</span>
|
||||
<time :datetime="notification.createdAt">{{ formatDateTime(notification.createdAt) }}</time>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -353,6 +353,7 @@ export interface LifePost {
|
||||
body: string;
|
||||
moderationStatus: AiModerationStatus;
|
||||
moderationLanguageCode: string | null;
|
||||
moderationReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
@@ -399,6 +400,7 @@ export interface LifeComment {
|
||||
deleted: boolean;
|
||||
moderationStatus: AiModerationStatus;
|
||||
moderationLanguageCode: string | null;
|
||||
moderationReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
@@ -449,6 +451,7 @@ export interface NotificationItem {
|
||||
target: NotificationTarget;
|
||||
reactionType: LifeReactionType | null;
|
||||
moderationStatus: NotificationModerationStatus | null;
|
||||
moderationReason: string | null;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -485,6 +488,7 @@ export type NotificationWsMessage =
|
||||
target: NotificationTarget;
|
||||
moderationStatus: NotificationModerationStatus;
|
||||
moderationLanguageCode: string | null;
|
||||
moderationReason: string | null;
|
||||
};
|
||||
|
||||
export const moderationUpdateEvent = 'pokopia-moderation-update';
|
||||
@@ -781,6 +785,7 @@ export interface EntityDiscussionComment {
|
||||
deleted: boolean;
|
||||
moderationStatus: AiModerationStatus;
|
||||
moderationLanguageCode: string | null;
|
||||
moderationReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
|
||||
@@ -611,6 +611,14 @@ svg {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.notification-item__detail {
|
||||
color: var(--ink-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.notification-item__copy time {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
@@ -2474,6 +2482,36 @@ button:disabled,
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.life-moderation-detail {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-width: 72ch;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--warning) 40%, var(--line));
|
||||
border-left: 4px solid var(--warning);
|
||||
border-radius: var(--radius-control);
|
||||
background: color-mix(in srgb, var(--warning) 10%, var(--surface));
|
||||
color: var(--ink-soft);
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.life-moderation-detail strong {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 950;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.life-moderation-detail--comment {
|
||||
max-width: 100%;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.life-form__actions,
|
||||
.life-auth-note {
|
||||
display: flex;
|
||||
|
||||
@@ -208,6 +208,10 @@ function canManageComment(comment: LifeComment) {
|
||||
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
|
||||
}
|
||||
|
||||
function canSeeCommentModeration(comment: LifeComment) {
|
||||
return currentUser.value?.id === comment.author?.id || can('life.comments.delete-any');
|
||||
}
|
||||
|
||||
function canUseReactions() {
|
||||
return canReact.value && reactionBusyPostId.value === null;
|
||||
}
|
||||
@@ -277,6 +281,10 @@ function canRetryModeration(currentPost: LifePost) {
|
||||
return currentPost.moderationStatus !== 'approved' && currentPost.moderationStatus !== 'reviewing' && canManage(currentPost);
|
||||
}
|
||||
|
||||
function moderationReasonVisible(status: AiModerationStatus, reason: string | null) {
|
||||
return (status === 'rejected' || status === 'failed') && reason !== null && reason.trim() !== '';
|
||||
}
|
||||
|
||||
function replacePost(updatedPost: LifePost) {
|
||||
post.value = updatedPost;
|
||||
commentsTotal.value = updatedPost.commentCount;
|
||||
@@ -286,16 +294,18 @@ function updateLifeCommentModeration(
|
||||
items: LifeComment[],
|
||||
commentId: number,
|
||||
status: AiModerationStatus,
|
||||
languageCode: string | null
|
||||
languageCode: string | null,
|
||||
reason: string | null
|
||||
): boolean {
|
||||
for (const comment of items) {
|
||||
if (comment.id === commentId) {
|
||||
comment.moderationStatus = status;
|
||||
comment.moderationLanguageCode = languageCode;
|
||||
comment.moderationReason = reason;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (updateLifeCommentModeration(comment.replies, commentId, status, languageCode)) {
|
||||
if (updateLifeCommentModeration(comment.replies, commentId, status, languageCode, reason)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -312,12 +322,13 @@ function handleModerationUpdate(event: Event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { target, moderationStatus, moderationLanguageCode } = event.detail;
|
||||
const { target, moderationStatus, moderationLanguageCode, moderationReason } = event.detail;
|
||||
if (target.type === 'life-post' && target.lifePostId === post.value.id) {
|
||||
post.value = {
|
||||
...post.value,
|
||||
moderationStatus,
|
||||
moderationLanguageCode
|
||||
moderationLanguageCode,
|
||||
moderationReason
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -326,7 +337,13 @@ function handleModerationUpdate(event: Event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = updateLifeCommentModeration(comments.value, target.lifeCommentId, moderationStatus, moderationLanguageCode);
|
||||
const updated = updateLifeCommentModeration(
|
||||
comments.value,
|
||||
target.lifeCommentId,
|
||||
moderationStatus,
|
||||
moderationLanguageCode,
|
||||
moderationReason
|
||||
);
|
||||
if (updated) {
|
||||
comments.value = [...comments.value];
|
||||
} else if (moderationStatus === 'approved') {
|
||||
@@ -809,6 +826,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="canManage(post) && moderationReasonVisible(post.moderationStatus, post.moderationReason)" class="life-moderation-detail">
|
||||
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
||||
<span>{{ post.moderationReason }}</span>
|
||||
</p>
|
||||
|
||||
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
|
||||
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
|
||||
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>
|
||||
@@ -872,8 +894,21 @@ onUnmounted(() => {
|
||||
</RouterLink>
|
||||
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
||||
<time :datetime="comment.createdAt">{{ formatPostTime(comment.createdAt) }}</time>
|
||||
<StatusBadge
|
||||
v-if="canSeeCommentModeration(comment)"
|
||||
:label="moderationLabel(comment.moderationStatus)"
|
||||
:tone="moderationTone(comment.moderationStatus)"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
|
||||
<p
|
||||
v-if="canSeeCommentModeration(comment) && moderationReasonVisible(comment.moderationStatus, comment.moderationReason)"
|
||||
class="life-moderation-detail life-moderation-detail--comment"
|
||||
>
|
||||
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
||||
<span>{{ comment.moderationReason }}</span>
|
||||
</p>
|
||||
|
||||
<div v-if="!comment.deleted" class="life-comment__actions">
|
||||
<button
|
||||
@@ -947,8 +982,21 @@ onUnmounted(() => {
|
||||
</RouterLink>
|
||||
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
||||
<time :datetime="reply.createdAt">{{ formatPostTime(reply.createdAt) }}</time>
|
||||
<StatusBadge
|
||||
v-if="canSeeCommentModeration(reply)"
|
||||
:label="moderationLabel(reply.moderationStatus)"
|
||||
:tone="moderationTone(reply.moderationStatus)"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
|
||||
<p
|
||||
v-if="canSeeCommentModeration(reply) && moderationReasonVisible(reply.moderationStatus, reply.moderationReason)"
|
||||
class="life-moderation-detail life-moderation-detail--comment"
|
||||
>
|
||||
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
||||
<span>{{ reply.moderationReason }}</span>
|
||||
</p>
|
||||
<div v-if="canManageComment(reply)" class="life-comment__actions">
|
||||
<button
|
||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||
|
||||
@@ -478,6 +478,10 @@ function canManageComment(comment: LifeComment) {
|
||||
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
|
||||
}
|
||||
|
||||
function canSeeCommentModeration(comment: LifeComment) {
|
||||
return currentUser.value?.id === comment.author?.id || can('life.comments.delete-any');
|
||||
}
|
||||
|
||||
function commentKey(postId: number) {
|
||||
return `post-${postId}`;
|
||||
}
|
||||
@@ -579,20 +583,26 @@ function canRetryModeration(post: LifePost) {
|
||||
return post.moderationStatus !== 'approved' && post.moderationStatus !== 'reviewing' && canManage(post);
|
||||
}
|
||||
|
||||
function moderationReasonVisible(status: AiModerationStatus, reason: string | null) {
|
||||
return (status === 'rejected' || status === 'failed') && reason !== null && reason.trim() !== '';
|
||||
}
|
||||
|
||||
function updateLifeCommentModeration(
|
||||
items: LifeComment[],
|
||||
commentId: number,
|
||||
status: AiModerationStatus,
|
||||
languageCode: string | null
|
||||
languageCode: string | null,
|
||||
reason: string | null
|
||||
): boolean {
|
||||
for (const comment of items) {
|
||||
if (comment.id === commentId) {
|
||||
comment.moderationStatus = status;
|
||||
comment.moderationLanguageCode = languageCode;
|
||||
comment.moderationReason = reason;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (updateLifeCommentModeration(comment.replies, commentId, status, languageCode)) {
|
||||
if (updateLifeCommentModeration(comment.replies, commentId, status, languageCode, reason)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -609,7 +619,7 @@ function handleModerationUpdate(event: Event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { target, moderationStatus, moderationLanguageCode } = event.detail;
|
||||
const { target, moderationStatus, moderationLanguageCode, moderationReason } = event.detail;
|
||||
if (target.type === 'life-post' && target.lifePostId !== null) {
|
||||
const currentPost = posts.value.find((post) => post.id === target.lifePostId);
|
||||
if (!currentPost) {
|
||||
@@ -619,7 +629,8 @@ function handleModerationUpdate(event: Event) {
|
||||
const updatedPost = {
|
||||
...currentPost,
|
||||
moderationStatus,
|
||||
moderationLanguageCode
|
||||
moderationLanguageCode,
|
||||
moderationReason
|
||||
};
|
||||
if (!matchesCurrentFilters(updatedPost)) {
|
||||
posts.value = posts.value.filter((post) => post.id !== updatedPost.id);
|
||||
@@ -639,7 +650,13 @@ function handleModerationUpdate(event: Event) {
|
||||
}
|
||||
|
||||
const page = commentPage(currentPost);
|
||||
const updated = updateLifeCommentModeration(page.items, target.lifeCommentId, moderationStatus, moderationLanguageCode);
|
||||
const updated = updateLifeCommentModeration(
|
||||
page.items,
|
||||
target.lifeCommentId,
|
||||
moderationStatus,
|
||||
moderationLanguageCode,
|
||||
moderationReason
|
||||
);
|
||||
if (updated) {
|
||||
setCommentPage(currentPost.id, { ...page, items: [...page.items] });
|
||||
} else if (moderationStatus === 'approved' && areCommentsExpanded(currentPost.id)) {
|
||||
@@ -1488,6 +1505,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="canManage(post) && moderationReasonVisible(post.moderationStatus, post.moderationReason)" class="life-moderation-detail">
|
||||
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
||||
<span>{{ post.moderationReason }}</span>
|
||||
</p>
|
||||
|
||||
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
|
||||
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
|
||||
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>
|
||||
@@ -1556,8 +1578,21 @@ onUnmounted(() => {
|
||||
</RouterLink>
|
||||
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
||||
<time :datetime="comment.createdAt">{{ formatPostTime(comment.createdAt) }}</time>
|
||||
<StatusBadge
|
||||
v-if="canSeeCommentModeration(comment)"
|
||||
:label="moderationLabel(comment.moderationStatus)"
|
||||
:tone="moderationTone(comment.moderationStatus)"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
|
||||
<p
|
||||
v-if="canSeeCommentModeration(comment) && moderationReasonVisible(comment.moderationStatus, comment.moderationReason)"
|
||||
class="life-moderation-detail life-moderation-detail--comment"
|
||||
>
|
||||
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
||||
<span>{{ comment.moderationReason }}</span>
|
||||
</p>
|
||||
|
||||
<div v-if="!comment.deleted" class="life-comment__actions">
|
||||
<button
|
||||
@@ -1631,8 +1666,21 @@ onUnmounted(() => {
|
||||
</RouterLink>
|
||||
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
||||
<time :datetime="reply.createdAt">{{ formatPostTime(reply.createdAt) }}</time>
|
||||
<StatusBadge
|
||||
v-if="canSeeCommentModeration(reply)"
|
||||
:label="moderationLabel(reply.moderationStatus)"
|
||||
:tone="moderationTone(reply.moderationStatus)"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
|
||||
<p
|
||||
v-if="canSeeCommentModeration(reply) && moderationReasonVisible(reply.moderationStatus, reply.moderationReason)"
|
||||
class="life-moderation-detail life-moderation-detail--comment"
|
||||
>
|
||||
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
||||
<span>{{ reply.moderationReason }}</span>
|
||||
</p>
|
||||
<div v-if="canManageComment(reply)" class="life-comment__actions">
|
||||
<button
|
||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||
|
||||
Reference in New Issue
Block a user