feat(moderation): make moderation more lenient and add timeout fallback behavior
The moderation system now uses a "pass/review" strategy instead of "pass/review/block", treating hard violations the same as review (only routing to manual review rather than auto-rejecting). Add fallback-approve behavior in the hook system so slow moderation APIs never silently block normal content when they time out or get cancelled - timed-out moderation now approves with "ReviewedBy: system" instead of leaving the zero-value result that would reject content. Update post/comment/vote services and handlers to use the new unified behavior. Simplify moderation prompts to emphasize "宁可放过,不可误伤" (prefer false positives over false negatives).
This commit is contained in:
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
@@ -170,7 +171,7 @@ func (s *commentService) reviewCommentAsync(
|
||||
fmt.Sscanf(userID, "%d", &authorID)
|
||||
|
||||
result := &hook.ModerationResult{}
|
||||
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookCommentPreModerate, authorID, &hook.CommentModerateHookData{
|
||||
triggerErr := s.hookManager.TriggerWithMetadata(context.Background(), hook.HookCommentPreModerate, authorID, &hook.CommentModerateHookData{
|
||||
CommentID: commentID,
|
||||
PostID: postID,
|
||||
Content: content,
|
||||
@@ -181,6 +182,19 @@ func (s *commentService) reviewCommentAsync(
|
||||
"result": result,
|
||||
})
|
||||
|
||||
// Fallback-approve when a moderation hook times out or is cancelled, so a
|
||||
// slow moderation API never silently rejects an otherwise-fine comment.
|
||||
if triggerErr != nil && (errors.Is(triggerErr, context.DeadlineExceeded) || errors.Is(triggerErr, context.Canceled)) {
|
||||
zap.L().Warn("Comment moderation hook timed out or was cancelled, fallback approve",
|
||||
zap.String("comment_id", commentID),
|
||||
zap.Error(triggerErr),
|
||||
)
|
||||
result.Approved = true
|
||||
result.ReviewedBy = "system"
|
||||
result.RejectReason = ""
|
||||
result.NeedsReview = false
|
||||
}
|
||||
|
||||
s.hookManager.Trigger(context.Background(), hook.HookCommentModerated, authorID, &hook.CommentModeratedHookData{
|
||||
CommentID: commentID,
|
||||
PostID: postID,
|
||||
|
||||
@@ -223,15 +223,13 @@ func (s *postAIService) ModeratePost(ctx context.Context, title, content string,
|
||||
zap.Error(err),
|
||||
)
|
||||
return s.moderateTextWithTencent(ctx, title+" "+content,
|
||||
func(reason string) error { return &PostModerationRejectedError{Reason: reason} },
|
||||
func(reason string) error { return &PostModerationReviewError{Reason: reason} },
|
||||
)
|
||||
}
|
||||
|
||||
// block 与 review 同等处理:均仅送人工复审。
|
||||
switch resp.Result {
|
||||
case openai.ModerationResultBlock:
|
||||
return &PostModerationRejectedError{Reason: resp.Reason}
|
||||
case openai.ModerationResultReview:
|
||||
case openai.ModerationResultBlock, openai.ModerationResultReview:
|
||||
return &PostModerationReviewError{Reason: resp.Reason}
|
||||
default:
|
||||
return nil
|
||||
@@ -239,7 +237,6 @@ func (s *postAIService) ModeratePost(ctx context.Context, title, content string,
|
||||
}
|
||||
|
||||
return s.moderateTextWithTencent(ctx, title+" "+content,
|
||||
func(reason string) error { return &PostModerationRejectedError{Reason: reason} },
|
||||
func(reason string) error { return &PostModerationReviewError{Reason: reason} },
|
||||
)
|
||||
}
|
||||
@@ -256,15 +253,13 @@ func (s *postAIService) ModerateComment(ctx context.Context, content string, ima
|
||||
zap.Error(err),
|
||||
)
|
||||
return s.moderateTextWithTencent(ctx, content,
|
||||
func(reason string) error { return &CommentModerationRejectedError{Reason: reason} },
|
||||
func(reason string) error { return &CommentModerationReviewError{Reason: reason} },
|
||||
)
|
||||
}
|
||||
|
||||
// block 与 review 同等处理:均仅送人工复审。
|
||||
switch resp.Result {
|
||||
case openai.ModerationResultBlock:
|
||||
return &CommentModerationRejectedError{Reason: resp.Reason}
|
||||
case openai.ModerationResultReview:
|
||||
case openai.ModerationResultBlock, openai.ModerationResultReview:
|
||||
return &CommentModerationReviewError{Reason: resp.Reason}
|
||||
default:
|
||||
return nil
|
||||
@@ -272,7 +267,6 @@ func (s *postAIService) ModerateComment(ctx context.Context, content string, ima
|
||||
}
|
||||
|
||||
return s.moderateTextWithTencent(ctx, content,
|
||||
func(reason string) error { return &CommentModerationRejectedError{Reason: reason} },
|
||||
func(reason string) error { return &CommentModerationReviewError{Reason: reason} },
|
||||
)
|
||||
}
|
||||
@@ -293,10 +287,9 @@ func (s *postAIService) ModerateImage(ctx context.Context, imageURL string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// block 与 review 同等处理:均仅送人工复审。
|
||||
switch resp.Result {
|
||||
case openai.ModerationResultBlock:
|
||||
return &ImageModerationRejectedError{Reason: resp.Reason}
|
||||
case openai.ModerationResultReview:
|
||||
case openai.ModerationResultBlock, openai.ModerationResultReview:
|
||||
return &ImageModerationReviewError{Reason: resp.Reason}
|
||||
default:
|
||||
return nil
|
||||
@@ -315,15 +308,13 @@ func (s *postAIService) ModerateBio(ctx context.Context, bio string) error {
|
||||
zap.Error(err),
|
||||
)
|
||||
return s.moderateTextWithTencent(ctx, bio,
|
||||
func(reason string) error { return &BioModerationRejectedError{Reason: reason} },
|
||||
func(reason string) error { return &BioModerationReviewError{Reason: reason} },
|
||||
)
|
||||
}
|
||||
|
||||
// block 与 review 同等处理:均仅送人工复审。
|
||||
switch resp.Result {
|
||||
case openai.ModerationResultBlock:
|
||||
return &BioModerationRejectedError{Reason: resp.Reason}
|
||||
case openai.ModerationResultReview:
|
||||
case openai.ModerationResultBlock, openai.ModerationResultReview:
|
||||
return &BioModerationReviewError{Reason: resp.Reason}
|
||||
default:
|
||||
return nil
|
||||
@@ -331,7 +322,6 @@ func (s *postAIService) ModerateBio(ctx context.Context, bio string) error {
|
||||
}
|
||||
|
||||
return s.moderateTextWithTencent(ctx, bio,
|
||||
func(reason string) error { return &BioModerationRejectedError{Reason: reason} },
|
||||
func(reason string) error { return &BioModerationReviewError{Reason: reason} },
|
||||
)
|
||||
}
|
||||
@@ -339,7 +329,6 @@ func (s *postAIService) ModerateBio(ctx context.Context, bio string) error {
|
||||
func (s *postAIService) moderateTextWithTencent(
|
||||
ctx context.Context,
|
||||
text string,
|
||||
rejectFactory func(string) error,
|
||||
reviewFactory func(string) error,
|
||||
) error {
|
||||
if !s.isTencentEnabled() {
|
||||
@@ -366,10 +355,9 @@ func (s *postAIService) moderateTextWithTencent(
|
||||
reason = resp.Label + ": " + strings.Join(resp.Keywords, ", ")
|
||||
}
|
||||
|
||||
// 腾讯云 block 也仅送审,与 AI 同语义。
|
||||
switch resp.Suggestion {
|
||||
case tencent.SuggestionBlock:
|
||||
return rejectFactory(reason)
|
||||
case tencent.SuggestionReview:
|
||||
case tencent.SuggestionBlock, tencent.SuggestionReview:
|
||||
return reviewFactory(reason)
|
||||
default:
|
||||
return nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
@@ -216,7 +217,7 @@ func (s *postServiceImpl) reviewPostAsync(postID, userID, title, content string,
|
||||
fmt.Sscanf(userID, "%d", &authorID)
|
||||
|
||||
result := &hook.ModerationResult{}
|
||||
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookPostPreModerate, authorID, &hook.PostModerateHookData{
|
||||
triggerErr := s.hookManager.TriggerWithMetadata(context.Background(), hook.HookPostPreModerate, authorID, &hook.PostModerateHookData{
|
||||
PostID: postID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
@@ -226,6 +227,19 @@ func (s *postServiceImpl) reviewPostAsync(postID, userID, title, content string,
|
||||
"result": result,
|
||||
})
|
||||
|
||||
// Trigger can return context.DeadlineExceeded/Canceled if a moderation hook
|
||||
// times out. In that case the result may not have been written at all
|
||||
// (result.Approved stays false). Treat an unfinished moderation as
|
||||
// "approved + system" rather than silently rejecting the post, so a slow
|
||||
// moderation API never blocks normal content.
|
||||
if triggerErr != nil && (errors.Is(triggerErr, context.DeadlineExceeded) || errors.Is(triggerErr, context.Canceled)) {
|
||||
log.Printf("[WARN] Post moderation hook timed out or was cancelled, fallback approve post=%s err=%v", postID, triggerErr)
|
||||
result.Approved = true
|
||||
result.ReviewedBy = "system"
|
||||
result.RejectReason = ""
|
||||
result.NeedsReview = false
|
||||
}
|
||||
|
||||
s.hookManager.Trigger(context.Background(), hook.HookPostModerated, authorID, &hook.PostModeratedHookData{
|
||||
PostID: postID,
|
||||
AuthorID: authorID,
|
||||
|
||||
@@ -177,7 +177,7 @@ func (s *voteService) reviewVotePostAsync(postID, userID, title, content string,
|
||||
}
|
||||
|
||||
result := &hook.ModerationResult{}
|
||||
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookPostPreModerate, authorID, &hook.PostModerateHookData{
|
||||
triggerErr := s.hookManager.TriggerWithMetadata(context.Background(), hook.HookPostPreModerate, authorID, &hook.PostModerateHookData{
|
||||
PostID: postID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
@@ -187,6 +187,16 @@ func (s *voteService) reviewVotePostAsync(postID, userID, title, content string,
|
||||
"result": result,
|
||||
})
|
||||
|
||||
// Fallback-approve when a moderation hook times out or is cancelled, so a
|
||||
// slow moderation API never silently rejects an otherwise-fine vote post.
|
||||
if triggerErr != nil && (errors.Is(triggerErr, context.DeadlineExceeded) || errors.Is(triggerErr, context.Canceled)) {
|
||||
log.Printf("[WARN] Vote post moderation hook timed out or was cancelled, fallback approve post=%s err=%v", postID, triggerErr)
|
||||
result.Approved = true
|
||||
result.ReviewedBy = "system"
|
||||
result.RejectReason = ""
|
||||
result.NeedsReview = false
|
||||
}
|
||||
|
||||
s.hookManager.Trigger(context.Background(), hook.HookPostModerated, authorID, &hook.PostModeratedHookData{
|
||||
PostID: postID,
|
||||
AuthorID: authorID,
|
||||
|
||||
Reference in New Issue
Block a user