feat(compliance): add content compliance detection across tenant, ops, and clients

Implements the Revision 8/9 compliance design: tenant + ops backends, ops-web
content-safety pages, admin-web editor/publish gate integration, desktop publish
block surfacing, and supporting migrations / shared types / config plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 20:48:14 +08:00
parent 81577b6154
commit 745cdd79cf
73 changed files with 12747 additions and 1892 deletions
+96
View File
@@ -7,6 +7,7 @@ import (
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -34,6 +35,7 @@ type Config struct {
JWT JWTConfig `mapstructure:"jwt"`
Log LogConfig `mapstructure:"log"`
LLM LLMConfig `mapstructure:"llm"`
Compliance ComplianceConfig `mapstructure:"compliance"`
Retrieval RetrievalConfig `mapstructure:"retrieval"`
Generation GenerationConfig `mapstructure:"generation"`
}
@@ -137,6 +139,12 @@ type RabbitMQConfig struct {
ImageAssetDLX string `mapstructure:"image_asset_dlx"`
ImageAssetDLQ string `mapstructure:"image_asset_dlq"`
ImageAssetDLQRouteKey string `mapstructure:"image_asset_dlq_routing_key"`
ComplianceReviewExchange string `mapstructure:"compliance_review_exchange"`
ComplianceReviewRoutingKey string `mapstructure:"compliance_review_routing_key"`
ComplianceReviewQueue string `mapstructure:"compliance_review_queue"`
ComplianceReviewDLX string `mapstructure:"compliance_review_dlx"`
ComplianceReviewDLQ string `mapstructure:"compliance_review_dlq"`
ComplianceReviewDLQRouteKey string `mapstructure:"compliance_review_dlq_routing_key"`
ConsumerPrefetch int `mapstructure:"consumer_prefetch"`
PublishChannelPoolSize int `mapstructure:"publish_channel_pool_size"`
}
@@ -282,6 +290,16 @@ type LLMConfig struct {
WebSearchLimit int32 `mapstructure:"web_search_limit"`
}
type ComplianceConfig struct {
Enabled bool `mapstructure:"enabled"`
LLMJudgeEnabled bool `mapstructure:"llm_judge_enabled"`
SnapshotReloadInterval time.Duration `mapstructure:"snapshot_reload_interval"`
PublishGateTimeout time.Duration `mapstructure:"publish_gate_timeout"`
ReviewWorkerConcurrency int `mapstructure:"review_worker_concurrency"`
ReviewWorkerTimeout time.Duration `mapstructure:"review_worker_timeout"`
ReviewMaxAttempts int `mapstructure:"review_max_attempts"`
}
type RetrievalConfig struct {
Provider string `mapstructure:"provider"`
BaseURL string `mapstructure:"base_url"`
@@ -356,6 +374,7 @@ func loadWithFiles(configPath string) (*Config, []string, error) {
normalizeMonitoringDispatchConfig(&cfg.MonitoringDispatch)
normalizeMembershipConfig(&cfg.Membership)
normalizeBrandLibraryConfig(&cfg.BrandLibrary)
NormalizeComplianceConfig(&cfg.Compliance)
NormalizeGenerationConfig(&cfg.Generation)
files := []string{configFile}
@@ -432,6 +451,10 @@ func applyConfigDefaults(settings map[string]any) {
if _, ok := cacheSettings["metrics_enabled"]; !ok {
cacheSettings["metrics_enabled"] = true
}
complianceSettings := ensureMapSetting(settings, "compliance")
if _, ok := complianceSettings["enabled"]; !ok {
complianceSettings["enabled"] = true
}
}
func ensureMapSetting(settings map[string]any, key string) map[string]any {
@@ -566,6 +589,27 @@ func NormalizeGenerationConfig(cfg *GenerationConfig) {
}
}
func NormalizeComplianceConfig(cfg *ComplianceConfig) {
if cfg == nil {
return
}
if cfg.SnapshotReloadInterval <= 0 {
cfg.SnapshotReloadInterval = 30 * time.Second
}
if cfg.PublishGateTimeout <= 0 {
cfg.PublishGateTimeout = 800 * time.Millisecond
}
if cfg.ReviewWorkerConcurrency <= 0 {
cfg.ReviewWorkerConcurrency = 1
}
if cfg.ReviewWorkerTimeout <= 0 {
cfg.ReviewWorkerTimeout = 45 * time.Second
}
if cfg.ReviewMaxAttempts <= 0 {
cfg.ReviewMaxAttempts = 3
}
}
func candidateConfigPaths(configPath string, local bool) []string {
trimmed := strings.TrimSpace(configPath)
if trimmed == "" {
@@ -628,6 +672,27 @@ func applyEnvOverrides(cfg *Config) {
if model, ok := lookupNonEmptyEnv("LLM_KNOWLEDGE_URL_MODEL"); ok {
cfg.LLM.KnowledgeURLModel = model
}
if enabled, ok := lookupBoolEnv("COMPLIANCE_ENABLED"); ok {
cfg.Compliance.Enabled = enabled
}
if enabled, ok := lookupBoolEnv("COMPLIANCE_LLM_ENABLED"); ok {
cfg.Compliance.LLMJudgeEnabled = enabled
}
if d, ok := lookupDurationEnv("COMPLIANCE_SNAPSHOT_RELOAD_INTERVAL"); ok {
cfg.Compliance.SnapshotReloadInterval = d
}
if d, ok := lookupDurationEnv("COMPLIANCE_PUBLISH_GATE_TIMEOUT"); ok {
cfg.Compliance.PublishGateTimeout = d
}
if n, ok := lookupIntEnv("COMPLIANCE_REVIEW_WORKER_CONCURRENCY"); ok {
cfg.Compliance.ReviewWorkerConcurrency = n
}
if d, ok := lookupDurationEnv("COMPLIANCE_REVIEW_WORKER_TIMEOUT"); ok {
cfg.Compliance.ReviewWorkerTimeout = d
}
if n, ok := lookupIntEnv("COMPLIANCE_REVIEW_MAX_ATTEMPTS"); ok {
cfg.Compliance.ReviewMaxAttempts = n
}
if apiKey, ok := lookupNonEmptyEnv("QDRANT_API_KEY"); ok {
cfg.Qdrant.APIKey = apiKey
}
@@ -832,6 +897,24 @@ func normalizeRabbitMQConfig(cfg *RabbitMQConfig) {
if strings.TrimSpace(cfg.ImageAssetDLQRouteKey) == "" {
cfg.ImageAssetDLQRouteKey = "image.asset.finalize.dlq"
}
if strings.TrimSpace(cfg.ComplianceReviewExchange) == "" {
cfg.ComplianceReviewExchange = "compliance.review"
}
if strings.TrimSpace(cfg.ComplianceReviewRoutingKey) == "" {
cfg.ComplianceReviewRoutingKey = "compliance.review.run"
}
if strings.TrimSpace(cfg.ComplianceReviewQueue) == "" {
cfg.ComplianceReviewQueue = "compliance.review.run"
}
if strings.TrimSpace(cfg.ComplianceReviewDLX) == "" {
cfg.ComplianceReviewDLX = "compliance.review.dlx"
}
if strings.TrimSpace(cfg.ComplianceReviewDLQ) == "" {
cfg.ComplianceReviewDLQ = "compliance.review.run.dlq"
}
if strings.TrimSpace(cfg.ComplianceReviewDLQRouteKey) == "" {
cfg.ComplianceReviewDLQRouteKey = "compliance.review.run.dlq"
}
if cfg.PublishChannelPoolSize <= 0 {
cfg.PublishChannelPoolSize = 16
}
@@ -1076,6 +1159,19 @@ func lookupDurationEnv(key string) (time.Duration, bool) {
return duration, true
}
func lookupIntEnv(key string) (int, bool) {
value, ok := lookupNonEmptyEnv(key)
if !ok {
return 0, false
}
number, err := strconv.Atoi(value)
if err != nil {
return 0, false
}
return number, true
}
func maxInt(value, fallback int) int {
if value < fallback {
return fallback