feat(moderation): add user profile content moderation for avatars, covers, and bios
This commit is contained in:
@@ -93,6 +93,8 @@ type Client interface {
|
||||
Config() Config
|
||||
ModeratePost(ctx context.Context, title, content string, images []string) (*ModerationResponse, error)
|
||||
ModerateComment(ctx context.Context, content string, images []string) (*ModerationResponse, error)
|
||||
ModerateImage(ctx context.Context, imageURL string) (*ModerationResponse, error)
|
||||
ModerateBio(ctx context.Context, bio string) (*ModerationResponse, error)
|
||||
}
|
||||
|
||||
type clientImpl struct {
|
||||
@@ -163,6 +165,157 @@ func (c *clientImpl) ModerateComment(ctx context.Context, content string, images
|
||||
return c.moderateContentInBatches(ctx, prompt.String(), images)
|
||||
}
|
||||
|
||||
const imageModerationSystemPrompt = `你是图片内容审核助手,负责审核用户头像和背景图片。目标是确保图片内容符合社区规范。请只输出指定JSON。
|
||||
|
||||
硬性违规(命中任一项必须 result=block):
|
||||
- 色情低俗、裸露、性暗示内容
|
||||
- 暴力血腥、恐怖画面
|
||||
- 违法犯罪相关内容
|
||||
- 明确的政治敏感宣传
|
||||
- 恶意广告、诈骗信息
|
||||
- 仇恨歧视符号或标志
|
||||
|
||||
可疑内容(result=review):
|
||||
- 图片含义模糊,无法确定是否存在违规
|
||||
- 轻微争议性内容但不完全违规
|
||||
- AI无法准确判断的内容
|
||||
|
||||
放行规则(result=pass):
|
||||
- 正常风景、人物、动物、动漫等图片
|
||||
- 表情包、梗图
|
||||
- 正常的艺术创作、摄影作品
|
||||
|
||||
输出格式(严格):
|
||||
仅输出一行JSON对象,不要Markdown,不要额外解释:
|
||||
{"result": "pass"/"review"/"block", "reason": "..."}`
|
||||
|
||||
const bioModerationSystemPrompt = `你是用户个性签名内容审核助手,负责审核用户个人简介/签名。目标是平衡社区安全与正常自我表达。请只输出指定JSON。
|
||||
|
||||
硬性违规(命中任一项必须 result=block):
|
||||
- 明确的违法犯罪、暴力威胁内容
|
||||
- 色情低俗、性暗示描述
|
||||
- 诈骗引流、恶意广告
|
||||
- 明确煽动群体对立、仇恨
|
||||
- 恶意公开他人隐私信息
|
||||
|
||||
可疑内容(result=review):
|
||||
- 内容含义模糊,无法确定违规意图
|
||||
- 涉及敏感话题但未明确违规
|
||||
- AI无法准确判断的表达
|
||||
|
||||
放行规则(result=pass):
|
||||
- 正常的自我介绍、心情表达
|
||||
- 玩梗、幽默、吐槽
|
||||
- 引用、歌词、诗句等
|
||||
- 理性的观点表达
|
||||
|
||||
输出格式(严格):
|
||||
仅输出一行JSON对象,不要Markdown,不要额外解释:
|
||||
{"result": "pass"/"review"/"block", "reason": "..."}`
|
||||
|
||||
func (c *clientImpl) ModerateImage(ctx context.Context, imageURL string) (*ModerationResponse, error) {
|
||||
if !c.IsEnabled() {
|
||||
return &ModerationResponse{Result: ModerationResultPass}, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(imageURL) == "" {
|
||||
return &ModerationResponse{Result: ModerationResultPass}, nil
|
||||
}
|
||||
|
||||
userPrompt := "请审核以下用户图片"
|
||||
cleanImages := normalizeImageURLs([]string{imageURL})
|
||||
optimizedImages := c.optimizeImagesForModeration(ctx, cleanImages)
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxModerationResultRetries; attempt++ {
|
||||
replyText, err := c.chatCompletion(ctx, c.cfg.ModerationModel, imageModerationSystemPrompt, userPrompt, optimizedImages, 0.1, 220)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
parsed := struct {
|
||||
Result string `json:"result"`
|
||||
Reason string `json:"reason"`
|
||||
}{}
|
||||
if err := json.Unmarshal([]byte(extractJSONObject(replyText)), &parsed); err != nil {
|
||||
lastErr = fmt.Errorf("failed to parse moderation result: %w", err)
|
||||
} else {
|
||||
result := ModerationResultPass
|
||||
switch parsed.Result {
|
||||
case "review":
|
||||
result = ModerationResultReview
|
||||
case "block":
|
||||
result = ModerationResultBlock
|
||||
}
|
||||
return &ModerationResponse{
|
||||
Result: result,
|
||||
Reason: parsed.Reason,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if attempt == maxModerationResultRetries-1 {
|
||||
break
|
||||
}
|
||||
if sleepErr := sleepWithBackoff(ctx, attempt); sleepErr != nil {
|
||||
return nil, sleepErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("image moderation failed after %d attempts: %w", maxModerationResultRetries, lastErr)
|
||||
}
|
||||
|
||||
func (c *clientImpl) ModerateBio(ctx context.Context, bio string) (*ModerationResponse, error) {
|
||||
if !c.IsEnabled() {
|
||||
return &ModerationResponse{Result: ModerationResultPass}, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(bio) == "" {
|
||||
return &ModerationResponse{Result: ModerationResultPass}, nil
|
||||
}
|
||||
|
||||
userPrompt := "个性签名内容:" + bio
|
||||
return c.moderateWithCustomPrompt(ctx, bioModerationSystemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
func (c *clientImpl) moderateWithCustomPrompt(ctx context.Context, systemPrompt string, userPrompt string) (*ModerationResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxModerationResultRetries; attempt++ {
|
||||
replyText, err := c.chatCompletion(ctx, c.cfg.ModerationModel, systemPrompt, userPrompt, nil, 0.1, 220)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
parsed := struct {
|
||||
Result string `json:"result"`
|
||||
Reason string `json:"reason"`
|
||||
}{}
|
||||
if err := json.Unmarshal([]byte(extractJSONObject(replyText)), &parsed); err != nil {
|
||||
lastErr = fmt.Errorf("failed to parse moderation result: %w", err)
|
||||
} else {
|
||||
result := ModerationResultPass
|
||||
switch parsed.Result {
|
||||
case "review":
|
||||
result = ModerationResultReview
|
||||
case "block":
|
||||
result = ModerationResultBlock
|
||||
}
|
||||
return &ModerationResponse{
|
||||
Result: result,
|
||||
Reason: parsed.Reason,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if attempt == maxModerationResultRetries-1 {
|
||||
break
|
||||
}
|
||||
if sleepErr := sleepWithBackoff(ctx, attempt); sleepErr != nil {
|
||||
return nil, sleepErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("moderation failed after %d attempts: %w", maxModerationResultRetries, lastErr)
|
||||
}
|
||||
|
||||
func (c *clientImpl) moderateContentInBatches(ctx context.Context, contentPrompt string, images []string) (*ModerationResponse, error) {
|
||||
cleanImages := normalizeImageURLs(images)
|
||||
optimizedImages := c.optimizeImagesForModeration(ctx, cleanImages)
|
||||
|
||||
Reference in New Issue
Block a user