feat: AI moderation audit log with admin feedback for training - DB migration, service methods, admin endpoints
This commit is contained in:
parent
c83317c29c
commit
1f0461b4f4
|
|
@ -464,6 +464,11 @@ func main() {
|
||||||
admin.GET("/ai/config", adminHandler.GetAIModerationConfigs)
|
admin.GET("/ai/config", adminHandler.GetAIModerationConfigs)
|
||||||
admin.PUT("/ai/config", adminHandler.SetAIModerationConfig)
|
admin.PUT("/ai/config", adminHandler.SetAIModerationConfig)
|
||||||
admin.POST("/ai/test", adminHandler.TestAIModeration)
|
admin.POST("/ai/test", adminHandler.TestAIModeration)
|
||||||
|
|
||||||
|
// AI Moderation Audit Log
|
||||||
|
admin.GET("/ai/moderation-log", adminHandler.GetAIModerationLog)
|
||||||
|
admin.POST("/ai/moderation-log/:id/feedback", adminHandler.SubmitAIModerationFeedback)
|
||||||
|
admin.GET("/ai/training-data", adminHandler.ExportAITrainingData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public claim request endpoint (no auth)
|
// Public claim request endpoint (no auth)
|
||||||
|
|
|
||||||
|
|
@ -2455,3 +2455,81 @@ func (h *AdminHandler) TestAIModeration(c *gin.Context) {
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"result": result})
|
c.JSON(http.StatusOK, gin.H{"result": result})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// AI Moderation Audit Log
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *AdminHandler) GetAIModerationLog(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
decision := c.Query("decision")
|
||||||
|
contentType := c.Query("content_type")
|
||||||
|
search := c.Query("search")
|
||||||
|
feedbackFilter := c.Query("feedback")
|
||||||
|
|
||||||
|
items, total, err := h.moderationService.GetAIModerationLog(ctx, limit, offset, decision, contentType, search, feedbackFilter)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to fetch AI moderation log")
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch AI moderation log"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) SubmitAIModerationFeedback(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
adminID, _ := c.Get("user_id")
|
||||||
|
logID := c.Param("id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Correct bool `json:"correct"`
|
||||||
|
Reason string `json:"reason" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logUUID, err := uuid.Parse(logID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid log ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminUUID, _ := uuid.Parse(adminID.(string))
|
||||||
|
|
||||||
|
if err := h.moderationService.SubmitAIFeedback(ctx, logUUID, req.Correct, req.Reason, adminUUID); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to submit AI feedback")
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit feedback"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'ai_moderation_feedback', 'ai_moderation_log', $2, $3)`,
|
||||||
|
adminUUID, logID, fmt.Sprintf(`{"correct":%v,"reason":"%s"}`, req.Correct, req.Reason))
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Feedback submitted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ExportAITrainingData(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
data, err := h.moderationService.GetAITrainingData(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to export AI training data")
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to export training data"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"training_data": data,
|
||||||
|
"count": len(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,17 @@ func (h *PostHandler) CreateComment(c *gin.Context) {
|
||||||
_ = h.moderationService.FlagComment(c.Request.Context(), post.ID, scores, reason)
|
_ = h.moderationService.FlagComment(c.Request.Context(), post.ID, scores, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log AI moderation decision for comment
|
||||||
|
if h.moderationService != nil {
|
||||||
|
decision := "pass"
|
||||||
|
if post.Status == "pending_moderation" {
|
||||||
|
decision = "flag"
|
||||||
|
}
|
||||||
|
invCis := 1.0 - cis
|
||||||
|
scores := &services.ThreePoisonsScore{Hate: invCis, Greed: 0, Delusion: 0}
|
||||||
|
h.moderationService.LogAIDecision(c.Request.Context(), "comment", post.ID, userID, req.Body, scores, nil, decision, tone, "", nil)
|
||||||
|
}
|
||||||
|
|
||||||
// Get post details for notification
|
// Get post details for notification
|
||||||
rootPost, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string))
|
rootPost, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string))
|
||||||
if err == nil && rootPost.AuthorID.String() != userIDStr.(string) {
|
if err == nil && rootPost.AuthorID.String() != userIDStr.(string) {
|
||||||
|
|
@ -373,6 +384,28 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
||||||
_ = h.moderationService.FlagPost(c.Request.Context(), post.ID, scores, reason)
|
_ = h.moderationService.FlagPost(c.Request.Context(), post.ID, scores, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log AI moderation decision to audit log
|
||||||
|
if h.moderationService != nil {
|
||||||
|
decision := "pass"
|
||||||
|
flagReason := ""
|
||||||
|
if post.ToneLabel != nil && *post.ToneLabel != "" {
|
||||||
|
flagReason = *post.ToneLabel
|
||||||
|
}
|
||||||
|
if post.Status == "pending_moderation" {
|
||||||
|
decision = "flag"
|
||||||
|
} else if post.IsNSFW {
|
||||||
|
decision = "nsfw"
|
||||||
|
}
|
||||||
|
var scores *services.ThreePoisonsScore
|
||||||
|
if post.CISScore != nil {
|
||||||
|
invCis := 1.0 - *post.CISScore
|
||||||
|
scores = &services.ThreePoisonsScore{Hate: invCis, Greed: 0, Delusion: 0}
|
||||||
|
} else {
|
||||||
|
scores = &services.ThreePoisonsScore{}
|
||||||
|
}
|
||||||
|
h.moderationService.LogAIDecision(c.Request.Context(), "post", post.ID, userID, req.Body, scores, nil, decision, flagReason, "", nil)
|
||||||
|
}
|
||||||
|
|
||||||
// Check for @mentions and notify mentioned users
|
// Check for @mentions and notify mentioned users
|
||||||
go func() {
|
go func() {
|
||||||
if h.notificationService != nil && strings.Contains(req.Body, "@") {
|
if h.notificationService != nil && strings.Contains(req.Body, "@") {
|
||||||
|
|
|
||||||
|
|
@ -516,6 +516,186 @@ func (s *ModerationService) UpdateUserStatus(ctx context.Context, userID uuid.UU
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI Moderation Audit Log
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// LogAIDecision records an AI moderation decision to the audit log
|
||||||
|
func (s *ModerationService) LogAIDecision(ctx context.Context, contentType string, contentID uuid.UUID, authorID uuid.UUID, contentSnippet string, scores *ThreePoisonsScore, rawScores json.RawMessage, decision string, flagReason string, orDecision string, orScores json.RawMessage) {
|
||||||
|
snippet := contentSnippet
|
||||||
|
if len(snippet) > 200 {
|
||||||
|
snippet = snippet[:200]
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO ai_moderation_log (content_type, content_id, author_id, content_snippet, decision, flag_reason, scores_hate, scores_greed, scores_delusion, raw_scores, or_decision, or_scores)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
`, contentType, contentID, authorID, snippet, decision, flagReason, scores.Hate, scores.Greed, scores.Delusion, rawScores, orDecision, orScores)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to log AI moderation decision: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAIModerationLog retrieves the AI moderation audit log with filters
|
||||||
|
func (s *ModerationService) GetAIModerationLog(ctx context.Context, limit, offset int, decision, contentType, search string, feedbackFilter string) ([]map[string]interface{}, int, error) {
|
||||||
|
where := "WHERE 1=1"
|
||||||
|
args := []interface{}{}
|
||||||
|
argIdx := 1
|
||||||
|
|
||||||
|
if decision != "" {
|
||||||
|
where += fmt.Sprintf(" AND aml.decision = $%d", argIdx)
|
||||||
|
args = append(args, decision)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if contentType != "" {
|
||||||
|
where += fmt.Sprintf(" AND aml.content_type = $%d", argIdx)
|
||||||
|
args = append(args, contentType)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if search != "" {
|
||||||
|
where += fmt.Sprintf(" AND (aml.content_snippet ILIKE '%%' || $%d || '%%' OR pr.handle ILIKE '%%' || $%d || '%%')", argIdx, argIdx)
|
||||||
|
args = append(args, search)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if feedbackFilter == "reviewed" {
|
||||||
|
where += " AND aml.feedback_correct IS NOT NULL"
|
||||||
|
} else if feedbackFilter == "unreviewed" {
|
||||||
|
where += " AND aml.feedback_correct IS NULL"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
var total int
|
||||||
|
countArgs := make([]interface{}, len(args))
|
||||||
|
copy(countArgs, args)
|
||||||
|
s.pool.QueryRow(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM ai_moderation_log aml LEFT JOIN profiles pr ON aml.author_id = pr.id %s`, where), countArgs...).Scan(&total)
|
||||||
|
|
||||||
|
// Fetch rows
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT aml.id, aml.content_type, aml.content_id, aml.author_id, aml.content_snippet,
|
||||||
|
aml.ai_provider, aml.decision, aml.flag_reason,
|
||||||
|
aml.scores_hate, aml.scores_greed, aml.scores_delusion, aml.raw_scores,
|
||||||
|
aml.or_decision, aml.or_scores,
|
||||||
|
aml.feedback_correct, aml.feedback_reason, aml.feedback_by, aml.feedback_at,
|
||||||
|
aml.created_at,
|
||||||
|
COALESCE(pr.handle, '') as author_handle,
|
||||||
|
COALESCE(pr.display_name, '') as author_display_name
|
||||||
|
FROM ai_moderation_log aml
|
||||||
|
LEFT JOIN profiles pr ON aml.author_id = pr.id
|
||||||
|
%s
|
||||||
|
ORDER BY aml.created_at DESC
|
||||||
|
LIMIT $%d OFFSET $%d
|
||||||
|
`, where, argIdx, argIdx+1)
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to query ai moderation log: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, contentID, authorID uuid.UUID
|
||||||
|
var cType, snippet, aiProvider, dec string
|
||||||
|
var flagReason, orDecision, feedbackReason *string
|
||||||
|
var feedbackBy *uuid.UUID
|
||||||
|
var feedbackCorrect *bool
|
||||||
|
var feedbackAt *time.Time
|
||||||
|
var scoresHate, scoresGreed, scoresDelusion float64
|
||||||
|
var rawScores, orScores []byte
|
||||||
|
var createdAt time.Time
|
||||||
|
var authorHandle, authorDisplayName string
|
||||||
|
|
||||||
|
if err := rows.Scan(&id, &cType, &contentID, &authorID, &snippet,
|
||||||
|
&aiProvider, &dec, &flagReason,
|
||||||
|
&scoresHate, &scoresGreed, &scoresDelusion, &rawScores,
|
||||||
|
&orDecision, &orScores,
|
||||||
|
&feedbackCorrect, &feedbackReason, &feedbackBy, &feedbackAt,
|
||||||
|
&createdAt,
|
||||||
|
&authorHandle, &authorDisplayName,
|
||||||
|
); err != nil {
|
||||||
|
fmt.Printf("Failed to scan ai moderation log row: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
item := map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"content_type": cType,
|
||||||
|
"content_id": contentID,
|
||||||
|
"author_id": authorID,
|
||||||
|
"content_snippet": snippet,
|
||||||
|
"ai_provider": aiProvider,
|
||||||
|
"decision": dec,
|
||||||
|
"flag_reason": flagReason,
|
||||||
|
"scores_hate": scoresHate,
|
||||||
|
"scores_greed": scoresGreed,
|
||||||
|
"scores_delusion": scoresDelusion,
|
||||||
|
"raw_scores": json.RawMessage(rawScores),
|
||||||
|
"or_decision": orDecision,
|
||||||
|
"or_scores": json.RawMessage(orScores),
|
||||||
|
"feedback_correct": feedbackCorrect,
|
||||||
|
"feedback_reason": feedbackReason,
|
||||||
|
"feedback_by": feedbackBy,
|
||||||
|
"feedback_at": feedbackAt,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"author_handle": authorHandle,
|
||||||
|
"author_display_name": authorDisplayName,
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitAIFeedback records admin training feedback on an AI moderation decision
|
||||||
|
func (s *ModerationService) SubmitAIFeedback(ctx context.Context, logID uuid.UUID, correct bool, reason string, adminID uuid.UUID) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE ai_moderation_log
|
||||||
|
SET feedback_correct = $1, feedback_reason = $2, feedback_by = $3, feedback_at = NOW()
|
||||||
|
WHERE id = $4
|
||||||
|
`, correct, reason, adminID, logID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to submit AI feedback: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAITrainingData exports all reviewed feedback entries for fine-tuning
|
||||||
|
func (s *ModerationService) GetAITrainingData(ctx context.Context) ([]map[string]interface{}, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT content_snippet, decision, flag_reason, scores_hate, scores_greed, scores_delusion,
|
||||||
|
feedback_correct, feedback_reason
|
||||||
|
FROM ai_moderation_log
|
||||||
|
WHERE feedback_correct IS NOT NULL
|
||||||
|
ORDER BY feedback_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var snippet, decision string
|
||||||
|
var flagReason, feedbackReason *string
|
||||||
|
var hate, greed, delusion float64
|
||||||
|
var correct bool
|
||||||
|
|
||||||
|
if err := rows.Scan(&snippet, &decision, &flagReason, &hate, &greed, &delusion, &correct, &feedbackReason); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, map[string]interface{}{
|
||||||
|
"content": snippet,
|
||||||
|
"ai_decision": decision,
|
||||||
|
"ai_flag_reason": flagReason,
|
||||||
|
"scores": map[string]float64{"hate": hate, "greed": greed, "delusion": delusion},
|
||||||
|
"correct": correct,
|
||||||
|
"feedback_reason": feedbackReason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
func containsAny(body string, terms []string) bool {
|
func containsAny(body string, terms []string) bool {
|
||||||
// Case insensitive check
|
// Case insensitive check
|
||||||
lower := bytes.ToLower([]byte(body))
|
lower := bytes.ToLower([]byte(body))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue