sojorn/go-backend/internal/handlers/moderation_handler.go
2026-02-15 00:33:24 -06:00

177 lines
5.5 KiB
Go

package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/patbritton/sojorn-backend/internal/services"
"github.com/rs/zerolog/log"
)
// ModerationHandler provides a stateless content moderation endpoint.
// It checks text and/or images for policy violations and returns pass/fail.
// NO content is stored — designed for pre-send checks (e.g. E2EE group messages).
type ModerationHandler struct {
moderationService *services.ModerationService
openRouterService *services.OpenRouterService
localAIService *services.LocalAIService
}
func NewModerationHandler(moderationService *services.ModerationService, openRouterService *services.OpenRouterService, localAIService *services.LocalAIService) *ModerationHandler {
return &ModerationHandler{
moderationService: moderationService,
openRouterService: openRouterService,
localAIService: localAIService,
}
}
// CheckContent is a stateless moderation endpoint.
// POST /moderate
// Request: { "text": "...", "image_url": "..." }
// Response: { "allowed": true/false, "reason": "...", "action": "pass|nsfw|flag" }
// Nothing is logged or stored — pure pass/fail gate for privacy.
func (h *ModerationHandler) CheckContent(c *gin.Context) {
var req struct {
Text string `json:"text"`
ImageURL string `json:"image_url"`
Context string `json:"context"` // "group", "beacon", or empty for generic
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
if req.Text == "" && req.ImageURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "text or image_url required"})
return
}
// Resolve which engines are enabled for this moderation context.
// Try context-specific config first (e.g. group_text), fall back to generic (text).
useLocalAI, useOpenAI, useOpenRouter := true, true, true // defaults: all engines
if h.openRouterService != nil {
configType := "text"
if req.Text != "" && req.Context != "" {
configType = req.Context + "_text"
} else if req.ImageURL != "" && req.Context != "" {
configType = req.Context + "_image"
} else if req.ImageURL != "" {
configType = "image"
}
cfg, err := h.openRouterService.GetModerationConfig(c.Request.Context(), configType)
if err != nil && req.Context != "" {
// Fall back to generic text/image config
fallbackType := "text"
if req.ImageURL != "" && req.Text == "" {
fallbackType = "image"
}
cfg, _ = h.openRouterService.GetModerationConfig(c.Request.Context(), fallbackType)
}
if cfg != nil && len(cfg.Engines) > 0 {
useLocalAI = cfg.HasEngine("local_ai")
useOpenAI = cfg.HasEngine("openai")
useOpenRouter = cfg.HasEngine("openrouter")
}
}
action := "pass"
reason := ""
engine := "" // tracks which engine flagged
// Stage 0: Local AI moderation (llama-guard, on-server, free, fast)
if useLocalAI && h.localAIService != nil && req.Text != "" {
localResult, err := h.localAIService.ModerateText(c.Request.Context(), req.Text)
if err != nil {
log.Debug().Err(err).Msg("Local AI moderation unavailable, falling through")
} else if localResult != nil && !localResult.Allowed {
action = "flag"
reason = localResult.Reason
engine = "local_ai"
}
}
// Stage 1: Three Poisons moderation (OpenAI + Google Vision)
if useOpenAI && h.moderationService != nil {
mediaURLs := []string{}
if req.ImageURL != "" {
mediaURLs = append(mediaURLs, req.ImageURL)
}
_, flagReason, err := h.moderationService.AnalyzeContent(c.Request.Context(), req.Text, mediaURLs)
if err == nil && flagReason != "" {
action = "flag"
reason = flagReason
if engine == "" {
engine = "openai"
}
}
}
// Stage 2: OpenRouter moderation (text + image)
if useOpenRouter && h.openRouterService != nil {
if req.Text != "" {
var textResult *services.ModerationResult
var textErr error
if req.Context != "" {
textResult, textErr = h.openRouterService.ModerateWithType(c.Request.Context(), req.Context+"_text", req.Text, nil)
}
if textResult == nil || textErr != nil {
textResult, textErr = h.openRouterService.ModerateText(c.Request.Context(), req.Text)
}
if textErr == nil && textResult != nil {
if textResult.Action == "flag" {
action = "flag"
reason = textResult.Reason
if engine == "" {
engine = "openrouter"
}
} else if textResult.Action == "nsfw" && action != "flag" {
action = "nsfw"
reason = textResult.NSFWReason
if engine == "" {
engine = "openrouter"
}
}
}
}
if req.ImageURL != "" && action != "flag" {
var imgResult *services.ModerationResult
var imgErr error
if req.Context != "" {
imgResult, imgErr = h.openRouterService.ModerateWithType(c.Request.Context(), req.Context+"_image", "", []string{req.ImageURL})
}
if imgResult == nil || imgErr != nil {
imgResult, imgErr = h.openRouterService.ModerateImage(c.Request.Context(), req.ImageURL)
}
if imgErr == nil && imgResult != nil {
if imgResult.Action == "flag" {
action = "flag"
reason = imgResult.Reason
if engine == "" {
engine = "openrouter"
}
} else if imgResult.Action == "nsfw" && action != "flag" {
action = "nsfw"
reason = imgResult.NSFWReason
if engine == "" {
engine = "openrouter"
}
}
}
}
}
allowed := action != "flag"
if action == "flag" {
log.Info().Str("action", action).Str("reason", reason).Str("engine", engine).Msg("Stateless moderation: content blocked")
}
c.JSON(http.StatusOK, gin.H{
"allowed": allowed,
"action": action,
"reason": reason,
"engine": engine,
})
}