feat(moderation): add Tencent TMS as fallback for AI content moderation
Add Tencent Cloud Text Moderation System (TMS) as a fallback moderation service when OpenAI is unavailable or returns an error. The service now checks OpenAI first, and falls back to Tencent TMS if it fails, providing better reliability for content moderation. Features: - New Tencent TMS configuration in config.yaml with environment variable overrides - New tencent package in internal/pkg/tencent/ with TMS client implementation - PostAIService now uses dual moderation with OpenAI primary and Tencent fallback - Strict mode configuration passed to PostAIService for consistent behavior BREAKING CHANGE: NewPostAIService now requires tencentClient and strictMode parameters - update wire configuration accordingly.
This commit is contained in:
115
.kilo/package-lock.json
generated
Normal file
115
.kilo/package-lock.json
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": ".kilo",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "7.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/plugin": {
|
||||
"version": "7.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.10.tgz",
|
||||
"integrity": "sha512-VJPhJC+E5WWu7XgEJzrVOxKJlwJ+OATwxEzgjqEPj8KN5N38YxUPBY/rzUTjv90x7nkzyk1rFGfCVqXdA/Koug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "7.2.10",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.97",
|
||||
"@opentui/solid": ">=0.1.97"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/sdk": {
|
||||
"version": "7.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.10.tgz",
|
||||
"integrity": "sha512-H6jGXYAhN/yjOGX3MRZ0OxyEAuRGY3VOwDbLTh4O6ljpgutFHaLvomDZ82qNVy7gl7AjJgi3SAQAt9UQpeGl/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,8 @@ func InitializeApp() (*App, error) {
|
||||
userProfileAuditRepository := repository.NewUserProfileAuditRepository(db)
|
||||
manager := wire.ProvideHookManager()
|
||||
openaiClient := wire.ProvideOpenAIClient(config)
|
||||
postAIService := wire.ProvidePostAIService(openaiClient)
|
||||
tencentClient := wire.ProvideTencentClient(config)
|
||||
postAIService := wire.ProvidePostAIService(openaiClient, tencentClient, config)
|
||||
commentRepository := repository.NewCommentRepository(db)
|
||||
sensitiveWordRepository := repository.NewSensitiveWordRepository(db)
|
||||
sensitiveService := wire.ProvideSensitiveService(sensitiveWordRepository, client, config)
|
||||
|
||||
@@ -176,6 +176,19 @@ openai:
|
||||
request_timeout: 30
|
||||
strict_moderation: false
|
||||
|
||||
# 腾讯云文本内容安全配置(作为 OpenAI 备用)
|
||||
# 当 OpenAI 不可用或审核失败时,使用腾讯TMS进行文本审核
|
||||
# 环境变量:
|
||||
# APP_TENCENT_TMS_ENABLED, APP_TENCENT_TMS_SECRET_ID, APP_TENCENT_TMS_SECRET_KEY
|
||||
# APP_TENCENT_TMS_REGION, APP_TENCENT_TMS_BIZ_TYPE, APP_TENCENT_TMS_TIMEOUT
|
||||
tencent_tms:
|
||||
enabled: false
|
||||
secret_id: ""
|
||||
secret_key: ""
|
||||
region: "ap-guangzhou"
|
||||
biz_type: "" # 可选,使用控制台配置的自定义策略
|
||||
timeout: 30
|
||||
|
||||
# SMTP发信配置(gomail.v2)
|
||||
# 环境变量:
|
||||
# APP_EMAIL_ENABLED, APP_EMAIL_HOST, APP_EMAIL_PORT
|
||||
|
||||
@@ -23,6 +23,7 @@ type Config struct {
|
||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||
Upload UploadConfig `mapstructure:"upload"`
|
||||
OpenAI OpenAIConfig `mapstructure:"openai"`
|
||||
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
|
||||
Email EmailConfig `mapstructure:"email"`
|
||||
ConversationCache ConversationCacheConfig `mapstructure:"conversation_cache"`
|
||||
GRPC GRPCConfig `mapstructure:"grpc"`
|
||||
@@ -111,6 +112,12 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("openai.moderation_max_images_per_request", 1)
|
||||
viper.SetDefault("openai.request_timeout", 30)
|
||||
viper.SetDefault("openai.strict_moderation", false)
|
||||
viper.SetDefault("tencent_tms.enabled", false)
|
||||
viper.SetDefault("tencent_tms.secret_id", "")
|
||||
viper.SetDefault("tencent_tms.secret_key", "")
|
||||
viper.SetDefault("tencent_tms.region", "ap-guangzhou")
|
||||
viper.SetDefault("tencent_tms.biz_type", "")
|
||||
viper.SetDefault("tencent_tms.timeout", 30)
|
||||
viper.SetDefault("email.enabled", false)
|
||||
viper.SetDefault("email.host", "")
|
||||
viper.SetDefault("email.port", 587)
|
||||
@@ -221,6 +228,13 @@ func Load(configPath string) (*Config, error) {
|
||||
cfg.OpenAI.RequestTimeout, _ = strconv.Atoi(getEnvOrDefault("APP_OPENAI_REQUEST_TIMEOUT", fmt.Sprintf("%d", cfg.OpenAI.RequestTimeout)))
|
||||
cfg.OpenAI.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_OPENAI_ENABLED", fmt.Sprintf("%t", cfg.OpenAI.Enabled)))
|
||||
cfg.OpenAI.StrictModeration, _ = strconv.ParseBool(getEnvOrDefault("APP_OPENAI_STRICT_MODERATION", fmt.Sprintf("%t", cfg.OpenAI.StrictModeration)))
|
||||
// TencentTMS 环境变量覆盖
|
||||
cfg.TencentTMS.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_TENCENT_TMS_ENABLED", fmt.Sprintf("%t", cfg.TencentTMS.Enabled)))
|
||||
cfg.TencentTMS.SecretID = getEnvOrDefault("APP_TENCENT_TMS_SECRET_ID", cfg.TencentTMS.SecretID)
|
||||
cfg.TencentTMS.SecretKey = getEnvOrDefault("APP_TENCENT_TMS_SECRET_KEY", cfg.TencentTMS.SecretKey)
|
||||
cfg.TencentTMS.Region = getEnvOrDefault("APP_TENCENT_TMS_REGION", cfg.TencentTMS.Region)
|
||||
cfg.TencentTMS.BizType = getEnvOrDefault("APP_TENCENT_TMS_BIZ_TYPE", cfg.TencentTMS.BizType)
|
||||
cfg.TencentTMS.Timeout, _ = strconv.Atoi(getEnvOrDefault("APP_TENCENT_TMS_TIMEOUT", fmt.Sprintf("%d", cfg.TencentTMS.Timeout)))
|
||||
cfg.Email.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_ENABLED", fmt.Sprintf("%t", cfg.Email.Enabled)))
|
||||
cfg.Email.Host = getEnvOrDefault("APP_EMAIL_HOST", cfg.Email.Host)
|
||||
cfg.Email.Port, _ = strconv.Atoi(getEnvOrDefault("APP_EMAIL_PORT", fmt.Sprintf("%d", cfg.Email.Port)))
|
||||
|
||||
@@ -24,3 +24,13 @@ type OpenAIConfig struct {
|
||||
RequestTimeout int `mapstructure:"request_timeout"`
|
||||
StrictModeration bool `mapstructure:"strict_moderation"`
|
||||
}
|
||||
|
||||
// TencentTMSConfig 腾讯云文本内容安全配置(作为 OpenAI 审核的备用)
|
||||
type TencentTMSConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
SecretID string `mapstructure:"secret_id"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
Region string `mapstructure:"region"`
|
||||
BizType string `mapstructure:"biz_type"`
|
||||
Timeout int `mapstructure:"timeout"`
|
||||
}
|
||||
|
||||
193
internal/pkg/tencent/client.go
Normal file
193
internal/pkg/tencent/client.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package tencent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
host = "tms.tencentcloudapi.com"
|
||||
service = "tms"
|
||||
version = "2020-12-29"
|
||||
action = "TextModeration"
|
||||
algorithm = "TC3-HMAC-SHA256"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
IsEnabled() bool
|
||||
TextModeration(ctx context.Context, content string) (*ModerationResponse, error)
|
||||
}
|
||||
|
||||
type clientImpl struct {
|
||||
cfg Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg Config) Client {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30
|
||||
}
|
||||
return &clientImpl{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(timeout) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *clientImpl) IsEnabled() bool {
|
||||
return c.cfg.Enabled && c.cfg.SecretID != "" && c.cfg.SecretKey != ""
|
||||
}
|
||||
|
||||
func (c *clientImpl) TextModeration(ctx context.Context, content string) (*ModerationResponse, error) {
|
||||
if !c.IsEnabled() {
|
||||
return nil, fmt.Errorf("tencent tms client is not enabled")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return &ModerationResponse{Suggestion: SuggestionPass}, nil
|
||||
}
|
||||
|
||||
encodedContent := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
reqBody := textModerationRequest{
|
||||
Content: encodedContent,
|
||||
BizType: c.cfg.BizType,
|
||||
}
|
||||
payload, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
timestamp := time.Now().Unix()
|
||||
authorization, err := c.sign(string(payload), timestamp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://"+host, strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", authorization)
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set("Host", host)
|
||||
req.Header.Set("X-TC-Action", action)
|
||||
req.Header.Set("X-TC-Timestamp", strconv.FormatInt(timestamp, 10))
|
||||
req.Header.Set("X-TC-Version", version)
|
||||
req.Header.Set("X-TC-Region", c.cfg.Region)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request tencent tms: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("tencent tms error status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var apiResp tencentAPIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Response.Error != nil {
|
||||
return nil, fmt.Errorf("tencent tms api error: code=%s message=%s",
|
||||
apiResp.Response.Error.Code,
|
||||
apiResp.Response.Error.Message)
|
||||
}
|
||||
|
||||
keywords := make([]string, 0, len(apiResp.Response.Keywords))
|
||||
for _, kw := range apiResp.Response.Keywords {
|
||||
keywords = append(keywords, kw.Word)
|
||||
}
|
||||
|
||||
result := &ModerationResponse{
|
||||
Suggestion: ModerationSuggestion(apiResp.Response.Suggestion),
|
||||
Label: apiResp.Response.Label,
|
||||
RiskLevel: apiResp.Response.RiskLevel,
|
||||
Keywords: keywords,
|
||||
RequestID: apiResp.Response.RequestID,
|
||||
}
|
||||
|
||||
zap.L().Debug("Tencent TMS moderation result",
|
||||
zap.String("suggestion", string(result.Suggestion)),
|
||||
zap.String("label", result.Label),
|
||||
zap.String("risk_level", result.RiskLevel),
|
||||
zap.Strings("keywords", result.Keywords),
|
||||
zap.String("request_id", result.RequestID),
|
||||
)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *clientImpl) sign(payload string, timestamp int64) (string, error) {
|
||||
date := time.Unix(timestamp, 0).UTC().Format("2006-01-02")
|
||||
|
||||
canonicalHeaders := "content-type:application/json; charset=utf-8\n" + "host:" + host + "\n"
|
||||
signedHeaders := "content-type;host"
|
||||
hashedPayload := sha256hex(payload)
|
||||
|
||||
canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s",
|
||||
"POST",
|
||||
"/",
|
||||
"",
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
hashedPayload,
|
||||
)
|
||||
|
||||
credentialScope := fmt.Sprintf("%s/%s/tc3_request", date, service)
|
||||
hashedCanonicalRequest := sha256hex(canonicalRequest)
|
||||
string2sign := fmt.Sprintf("%s\n%d\n%s\n%s",
|
||||
algorithm,
|
||||
timestamp,
|
||||
credentialScope,
|
||||
hashedCanonicalRequest,
|
||||
)
|
||||
|
||||
secretDate := hmacsha256(date, []byte("TC3"+c.cfg.SecretKey))
|
||||
secretService := hmacsha256(service, secretDate)
|
||||
secretSigning := hmacsha256("tc3_request", secretService)
|
||||
signature := hex.EncodeToString(hmacsha256(string2sign, secretSigning))
|
||||
|
||||
authorization := fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
||||
algorithm,
|
||||
c.cfg.SecretID,
|
||||
credentialScope,
|
||||
signedHeaders,
|
||||
signature,
|
||||
)
|
||||
|
||||
return authorization, nil
|
||||
}
|
||||
|
||||
func sha256hex(s string) string {
|
||||
b := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func hmacsha256(s string, key []byte) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write([]byte(s))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
58
internal/pkg/tencent/config.go
Normal file
58
internal/pkg/tencent/config.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package tencent
|
||||
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
SecretID string
|
||||
SecretKey string
|
||||
Region string
|
||||
BizType string
|
||||
Timeout int
|
||||
}
|
||||
|
||||
type ModerationSuggestion string
|
||||
|
||||
const (
|
||||
SuggestionPass ModerationSuggestion = "Pass"
|
||||
SuggestionReview ModerationSuggestion = "Review"
|
||||
SuggestionBlock ModerationSuggestion = "Block"
|
||||
)
|
||||
|
||||
type ModerationResponse struct {
|
||||
Suggestion ModerationSuggestion
|
||||
Label string
|
||||
RiskLevel string
|
||||
Keywords []string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
type textModerationRequest struct {
|
||||
Content string `json:"Content"`
|
||||
BizType string `json:"BizType,omitempty"`
|
||||
}
|
||||
|
||||
type tencentAPIResponse struct {
|
||||
Response struct {
|
||||
Suggestion string `json:"Suggestion"`
|
||||
Label string `json:"Label"`
|
||||
RiskLevel string `json:"RiskLevel"`
|
||||
Keywords []struct {
|
||||
Word string `json:"Word"`
|
||||
} `json:"Keywords,omitempty"`
|
||||
RequestID string `json:"RequestId"`
|
||||
Error *struct {
|
||||
Code string `json:"Code"`
|
||||
Message string `json:"Message"`
|
||||
} `json:"Error,omitempty"`
|
||||
} `json:"Response"`
|
||||
}
|
||||
|
||||
func ConfigFromAppConfig(cfg *Config) Config {
|
||||
return Config{
|
||||
Enabled: cfg.Enabled,
|
||||
SecretID: cfg.SecretID,
|
||||
SecretKey: cfg.SecretKey,
|
||||
Region: cfg.Region,
|
||||
BizType: cfg.BizType,
|
||||
Timeout: cfg.Timeout,
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"carrot_bbs/internal/pkg/openai"
|
||||
"carrot_bbs/internal/pkg/tencent"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -171,32 +173,49 @@ func (e *BioModerationReviewError) IsReview() bool {
|
||||
|
||||
type PostAIService struct {
|
||||
openAIClient openai.Client
|
||||
tencentClient tencent.Client
|
||||
strictMode bool
|
||||
}
|
||||
|
||||
func NewPostAIService(openAIClient openai.Client) *PostAIService {
|
||||
func NewPostAIService(openAIClient openai.Client, tencentClient tencent.Client, strictMode bool) *PostAIService {
|
||||
return &PostAIService{
|
||||
openAIClient: openAIClient,
|
||||
tencentClient: tencentClient,
|
||||
strictMode: strictMode,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostAIService) IsEnabled() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
return (s.openAIClient != nil && s.openAIClient.IsEnabled()) ||
|
||||
(s.tencentClient != nil && s.tencentClient.IsEnabled())
|
||||
}
|
||||
|
||||
func (s *PostAIService) isOpenAIEnabled() bool {
|
||||
return s != nil && s.openAIClient != nil && s.openAIClient.IsEnabled()
|
||||
}
|
||||
|
||||
func (s *PostAIService) isTencentEnabled() bool {
|
||||
return s != nil && s.tencentClient != nil && s.tencentClient.IsEnabled()
|
||||
}
|
||||
|
||||
func (s *PostAIService) ModeratePost(ctx context.Context, title, content string, images []string) error {
|
||||
if !s.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.isOpenAIEnabled() {
|
||||
resp, err := s.openAIClient.ModeratePost(ctx, title, content, images)
|
||||
if err != nil {
|
||||
if s.openAIClient.Config().StrictModeration {
|
||||
return err
|
||||
}
|
||||
zap.L().Warn("AI moderation failed, fallback allow",
|
||||
zap.L().Warn("AI post moderation failed, trying Tencent fallback",
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil
|
||||
return s.moderateTextWithTencent(ctx, title+" "+content,
|
||||
func(reason string) error { return &PostModerationRejectedError{Reason: reason} },
|
||||
func(reason string) error { return &PostModerationReviewError{Reason: reason} },
|
||||
)
|
||||
}
|
||||
|
||||
switch resp.Result {
|
||||
@@ -209,20 +228,27 @@ 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} },
|
||||
)
|
||||
}
|
||||
|
||||
func (s *PostAIService) ModerateComment(ctx context.Context, content string, images []string) error {
|
||||
if !s.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.isOpenAIEnabled() {
|
||||
resp, err := s.openAIClient.ModerateComment(ctx, content, images)
|
||||
if err != nil {
|
||||
if s.openAIClient.Config().StrictModeration {
|
||||
return err
|
||||
}
|
||||
zap.L().Warn("AI comment moderation failed, fallback allow",
|
||||
zap.L().Warn("AI comment moderation failed, trying Tencent fallback",
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil
|
||||
return s.moderateTextWithTencent(ctx, content,
|
||||
func(reason string) error { return &CommentModerationRejectedError{Reason: reason} },
|
||||
func(reason string) error { return &CommentModerationReviewError{Reason: reason} },
|
||||
)
|
||||
}
|
||||
|
||||
switch resp.Result {
|
||||
@@ -235,14 +261,20 @@ 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} },
|
||||
)
|
||||
}
|
||||
|
||||
func (s *PostAIService) ModerateImage(ctx context.Context, imageURL string) error {
|
||||
if !s.IsEnabled() {
|
||||
if !s.isOpenAIEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := s.openAIClient.ModerateImage(ctx, imageURL)
|
||||
if err != nil {
|
||||
if s.openAIClient.Config().StrictModeration {
|
||||
if s.strictMode {
|
||||
return err
|
||||
}
|
||||
zap.L().Warn("AI image moderation failed, fallback allow",
|
||||
@@ -266,15 +298,16 @@ func (s *PostAIService) ModerateBio(ctx context.Context, bio string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.isOpenAIEnabled() {
|
||||
resp, err := s.openAIClient.ModerateBio(ctx, bio)
|
||||
if err != nil {
|
||||
if s.openAIClient.Config().StrictModeration {
|
||||
return err
|
||||
}
|
||||
zap.L().Warn("AI bio moderation failed, fallback allow",
|
||||
zap.L().Warn("AI bio moderation failed, trying Tencent fallback",
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil
|
||||
return s.moderateTextWithTencent(ctx, bio,
|
||||
func(reason string) error { return &BioModerationRejectedError{Reason: reason} },
|
||||
func(reason string) error { return &BioModerationReviewError{Reason: reason} },
|
||||
)
|
||||
}
|
||||
|
||||
switch resp.Result {
|
||||
@@ -286,3 +319,49 @@ func (s *PostAIService) ModerateBio(ctx context.Context, bio string) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return s.moderateTextWithTencent(ctx, bio,
|
||||
func(reason string) error { return &BioModerationRejectedError{Reason: reason} },
|
||||
func(reason string) error { return &BioModerationReviewError{Reason: reason} },
|
||||
)
|
||||
}
|
||||
|
||||
func (s *PostAIService) moderateTextWithTencent(
|
||||
ctx context.Context,
|
||||
text string,
|
||||
rejectFactory func(string) error,
|
||||
reviewFactory func(string) error,
|
||||
) error {
|
||||
if !s.isTencentEnabled() {
|
||||
if s.strictMode {
|
||||
return fmt.Errorf("no moderation service available")
|
||||
}
|
||||
zap.L().Warn("No moderation service available, fallback allow")
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := s.tencentClient.TextModeration(ctx, text)
|
||||
if err != nil {
|
||||
if s.strictMode {
|
||||
return err
|
||||
}
|
||||
zap.L().Warn("Tencent TMS moderation failed, fallback allow",
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
reason := resp.Label
|
||||
if len(resp.Keywords) > 0 {
|
||||
reason = resp.Label + ": " + strings.Join(resp.Keywords, ", ")
|
||||
}
|
||||
|
||||
switch resp.Suggestion {
|
||||
case tencent.SuggestionBlock:
|
||||
return rejectFactory(reason)
|
||||
case tencent.SuggestionReview:
|
||||
return reviewFactory(reason)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"carrot_bbs/internal/pkg/openai"
|
||||
"carrot_bbs/internal/pkg/redis"
|
||||
"carrot_bbs/internal/pkg/s3"
|
||||
"carrot_bbs/internal/pkg/tencent"
|
||||
"carrot_bbs/internal/pkg/ws"
|
||||
"carrot_bbs/internal/repository"
|
||||
"carrot_bbs/internal/service"
|
||||
@@ -49,6 +50,7 @@ var InfrastructureSet = wire.NewSet(
|
||||
|
||||
// 外部服务客户端
|
||||
ProvideOpenAIClient,
|
||||
ProvideTencentClient,
|
||||
ProvideEmailClient,
|
||||
ProvideS3Client,
|
||||
|
||||
@@ -169,6 +171,18 @@ func ProvideOpenAIClient(cfg *config.Config) openai.Client {
|
||||
return openai.NewClient(openai.ConfigFromAppConfig(&cfg.OpenAI))
|
||||
}
|
||||
|
||||
// ProvideTencentClient 提供腾讯云TMS客户端
|
||||
func ProvideTencentClient(cfg *config.Config) tencent.Client {
|
||||
return tencent.NewClient(tencent.Config{
|
||||
Enabled: cfg.TencentTMS.Enabled,
|
||||
SecretID: cfg.TencentTMS.SecretID,
|
||||
SecretKey: cfg.TencentTMS.SecretKey,
|
||||
Region: cfg.TencentTMS.Region,
|
||||
BizType: cfg.TencentTMS.BizType,
|
||||
Timeout: cfg.TencentTMS.Timeout,
|
||||
})
|
||||
}
|
||||
|
||||
// ProvideEmailClient 提供邮件客户端
|
||||
func ProvideEmailClient(cfg *config.Config) email.Client {
|
||||
return email.NewClient(email.ConfigFromAppConfig(&cfg.Email))
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"carrot_bbs/internal/pkg/openai"
|
||||
"carrot_bbs/internal/pkg/redis"
|
||||
"carrot_bbs/internal/pkg/s3"
|
||||
"carrot_bbs/internal/pkg/tencent"
|
||||
"carrot_bbs/internal/pkg/ws"
|
||||
"carrot_bbs/internal/repository"
|
||||
"carrot_bbs/internal/service"
|
||||
@@ -87,8 +88,8 @@ func ProvideEmailService(client email.Client) service.EmailService {
|
||||
}
|
||||
|
||||
// ProvidePostAIService 提供 AI 服务
|
||||
func ProvidePostAIService(client openai.Client) *service.PostAIService {
|
||||
return service.NewPostAIService(client)
|
||||
func ProvidePostAIService(openAIClient openai.Client, tencentClient tencent.Client, cfg *config.Config) *service.PostAIService {
|
||||
return service.NewPostAIService(openAIClient, tencentClient, cfg.OpenAI.StrictModeration)
|
||||
}
|
||||
|
||||
// ProvidePushService 提供推送服务
|
||||
|
||||
Reference in New Issue
Block a user