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:
2026-05-04 11:18:54 +08:00
parent 3d6188748d
commit 07698e063d
12 changed files with 352 additions and 50 deletions

View File

@@ -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)"

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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"

View File

@@ -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"