Files
backend/internal/pkg/openai/client.go

684 lines
21 KiB
Go
Raw Normal View History

package openai
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
_ "image/png"
"io"
"net/http"
"net/url"
"strings"
"time"
"with_you/internal/pkg/netutil"
xdraw "golang.org/x/image/draw"
)
const moderationSystemPrompt = `你是中文社区的内容审核助手负责对"帖子标题、正文、配图"做联合审核原则是**宁可放过不可误伤**只有明确违法违规内容才标记 review 交人工复核其余一律 pass请只输出指定JSON
核心原则
- 除明确违法违规外不要拦截或送审正常内容包括尖锐吐槽玩梗二创争论
- 判定从严倾向放行宁可放过十个可疑不可误伤一个正常
只有命中**明确违法违规**时才输出 result=review
A. 违法犯罪明确宣传违法犯罪行为教唆或指导他人犯罪如毒品枪支诈骗教程
B. 真实暴力威胁针对具体个人或群体的实质性暴力威胁人身伤害恐吓
C. 色情低俗明确色情露骨性描写或诱导性内容正常人体艺术二次创作用感形象不在此列
D. 诈骗引流明确的诈骗信息钓鱼链接黑产引流
E. 未成年保护涉及未成年人的色情或剥削内容
F. 开盒/人肉故意公开他人可识别隐私信息身份证号住址联系方式定位轨迹并伴有曝光意图
G. 极端仇恨鼓动对特定族裔/宗教/残障群体的仇恨与暴力含纳粹等极端符号
以下情形即使存在也**一律 pass**不送审不拦截
- 正常玩梗表情包谐音梗二次创作无恶意的吐槽
- 非定向轻度口语化吐槽玩笑自嘲朋友间互动
- 对社会事件/作品的理性讨论观点争论即使语气尖锐用词激烈
- 一般性粗口调侃讽刺未煽动对立或人身攻击的
- 对政治人物/社会热点的评论争议性观点表达
- 含义模糊但无明显违法意图的内容
reason 要求
- result=review 中文10-30说明疑似违法点
- result=pass reason 为空字符串
输出格式严格
仅输出一行JSON对象不要Markdown不要额外解释
{"result": "pass"/"review", "reason": "..."}`
const (
defaultMaxImagesPerModerationRequest = 1
maxAllowedImagesPerModerationRequest = 4
maxModerationResultRetries = 3
initialRetryBackoff = 500 * time.Millisecond
maxDownloadImageBytes = 10 * 1024 * 1024
maxModerationImageSide = 1280
compressedJPEGQuality = 72
maxCompressedPayloadBytes = 1536 * 1024
)
type ModerationResult string
const (
ModerationResultPass ModerationResult = "pass"
ModerationResultReview ModerationResult = "review"
ModerationResultBlock ModerationResult = "block"
)
type ModerationResponse struct {
Result ModerationResult `json:"result"`
Reason string `json:"reason"`
}
type Client interface {
IsEnabled() bool
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 {
cfg Config
httpClient *http.Client
}
func NewClient(cfg Config) Client {
timeout := cfg.RequestTimeoutSeconds
if timeout <= 0 {
timeout = 30
}
return &clientImpl{
cfg: cfg,
httpClient: netutil.NewSafeHTTPClient(time.Duration(timeout) * time.Second),
}
}
func (c *clientImpl) IsEnabled() bool {
return c.cfg.Enabled && c.cfg.APIKey != "" && c.cfg.BaseURL != ""
}
func (c *clientImpl) Config() Config {
return c.cfg
}
func (c *clientImpl) ModeratePost(ctx context.Context, title, content string, images []string) (*ModerationResponse, error) {
if !c.IsEnabled() {
return &ModerationResponse{Result: ModerationResultPass}, nil
}
var prompt strings.Builder
if strings.TrimSpace(title) != "" {
prompt.WriteString("帖子标题:" + title)
}
if strings.TrimSpace(content) != "" {
if prompt.Len() > 0 {
prompt.WriteString("\n")
}
prompt.WriteString("帖子内容:" + content)
}
if prompt.Len() == 0 && len(normalizeImageURLs(images)) > 0 {
prompt.WriteString("帖子内容:[仅包含图片]")
}
if prompt.Len() == 0 {
prompt.WriteString("帖子内容:[空]")
}
return c.moderateContentInBatches(ctx, prompt.String(), images)
}
func (c *clientImpl) ModerateComment(ctx context.Context, content string, images []string) (*ModerationResponse, error) {
if !c.IsEnabled() {
return &ModerationResponse{Result: ModerationResultPass}, nil
}
var prompt strings.Builder
if strings.TrimSpace(content) != "" {
prompt.WriteString("评论内容:" + content)
} else if len(normalizeImageURLs(images)) > 0 {
prompt.WriteString("评论内容:[仅包含图片]")
} else {
prompt.WriteString("评论内容:[空]")
}
return c.moderateContentInBatches(ctx, prompt.String(), images)
}
const imageModerationSystemPrompt = `你是图片内容审核助手负责审核用户头像和背景图片原则是**宁可放过不可误伤**只有明确违法违规的图片才标记 review 交人工复核其余一律 pass请只输出指定JSON
核心原则
- 除明确违法违规外不要拦截或送审正常图片
- 判定从严倾向放行宁可放过十张可疑不可误伤一张正常
只有命中**明确违法违规**时才输出 result=review
- 色情低俗明确色情露骨性描写或诱导性图片正常人体艺术二次创作用感形象不在此列
- 真实暴力/血腥明确展示严重暴力凶杀虐待等违法犯罪画面
- 违法犯罪相关明确涉及毒品枪支诈骗黑产等内容
- 未成年保护涉及未成年人的色情或剥削图片
- 诈骗引流明确的诈骗二维码钓鱼链接黑产广告
- 极端仇恨纳粹等极端仇恨符号或标志
以下图片即使存在也**一律 pass**不送审不拦截
- 正常风景人物动物动漫等图片
- 表情包梗图二次创作
- 正常的艺术创作摄影作品
- 含义模糊但无明显违法意图的图片
reason 要求
- result=review 中文10-30说明疑似违法点
- result=pass reason 为空字符串
输出格式严格
仅输出一行JSON对象不要Markdown不要额外解释
{"result": "pass"/"review", "reason": "..."}`
const bioModerationSystemPrompt = `你是用户个性签名内容审核助手负责审核用户个人简介/签名原则是**宁可放过不可误伤**只有明确违法违规内容才标记 review 交人工复核其余一律 pass请只输出指定JSON
核心原则
- 除明确违法违规外不要拦截或送审正常签名
- 判定从严倾向放行宁可放过十个可疑不可误伤一个正常
只有命中**明确违法违规**时才输出 result=review
- 明确违法犯罪暴力威胁内容
- 色情低俗露骨性暗示描述
- 诈骗引流黑产广告
- 恶意公开他人可识别隐私信息
- 涉及未成年人的色情或剥削内容
以下签名即使存在也**一律 pass**不送审不拦截
- 正常的自我介绍心情表达
- 玩梗幽默吐槽谐音梗
- 引用歌词诗句等
- 理性的观点表达对社会事件的评论
- 含义模糊但无明显违法意图的内容
reason 要求
- result=review 中文10-30说明疑似违法点
- result=pass reason 为空字符串
输出格式严格
仅输出一行JSON对象不要Markdown不要额外解释
{"result": "pass"/"review", "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)
resp, err := c.moderateWithRetry(ctx, imageModerationSystemPrompt, userPrompt, optimizedImages)
if err != nil {
return nil, fmt.Errorf("image moderation failed after %d attempts: %w", maxModerationResultRetries, err)
}
return resp, nil
}
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) {
resp, err := c.moderateWithRetry(ctx, systemPrompt, userPrompt, nil)
if err != nil {
return nil, fmt.Errorf("moderation failed after %d attempts: %w", maxModerationResultRetries, err)
}
return resp, nil
}
func (c *clientImpl) moderateContentInBatches(ctx context.Context, contentPrompt string, images []string) (*ModerationResponse, error) {
cleanImages := normalizeImageURLs(images)
optimizedImages := c.optimizeImagesForModeration(ctx, cleanImages)
maxImagesPerRequest := c.maxImagesPerModerationRequest()
totalBatches := 1
if len(optimizedImages) > 0 {
totalBatches = (len(optimizedImages) + maxImagesPerRequest - 1) / maxImagesPerRequest
}
for i := 0; i < totalBatches; i++ {
start := i * maxImagesPerRequest
end := start + maxImagesPerRequest
if end > len(optimizedImages) {
end = len(optimizedImages)
}
batchImages := []string{}
if len(optimizedImages) > 0 {
batchImages = optimizedImages[start:end]
}
resp, err := c.moderateSingleBatch(ctx, contentPrompt, batchImages, i+1, totalBatches)
if err != nil {
return nil, err
}
if resp.Result != ModerationResultPass {
if totalBatches > 1 && strings.TrimSpace(resp.Reason) != "" {
resp.Reason = fmt.Sprintf("第%d/%d批图片未通过%s", i+1, totalBatches, resp.Reason)
}
return resp, nil
}
}
return &ModerationResponse{Result: ModerationResultPass}, nil
}
func (c *clientImpl) moderateSingleBatch(
ctx context.Context,
contentPrompt string,
images []string,
batchNo, totalBatches int,
) (*ModerationResponse, error) {
userPrompt := fmt.Sprintf(
"%s\n图片批次%d/%d本次仅提供当前批次图片",
contentPrompt,
batchNo,
totalBatches,
)
resp, err := c.moderateWithRetry(ctx, moderationSystemPrompt, userPrompt, images)
if err != nil {
return nil, fmt.Errorf(
"moderation batch %d/%d failed after %d attempts: %w",
batchNo,
totalBatches,
maxModerationResultRetries,
err,
)
}
return resp, nil
}
type chatCompletionsRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
EnableThinking *bool `json:"enable_thinking,omitempty"`
ThinkingBudget *int `json:"thinking_budget,omitempty"`
ResponseFormat *responseFormatConfig `json:"response_format,omitempty"`
}
type responseFormatConfig struct {
Type string `json:"type"`
}
type chatMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
type contentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *imageURLPart `json:"image_url,omitempty"`
}
type imageURLPart struct {
URL string `json:"url"`
}
type chatCompletionsResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
func (c *clientImpl) chatCompletion(
ctx context.Context,
model string,
systemPrompt string,
userPrompt string,
images []string,
temperature float64,
maxTokens int,
) (string, error) {
if model == "" {
return "", fmt.Errorf("model is empty")
}
cleanImages := normalizeImageURLs(images)
userParts := []contentPart{
{Type: "text", Text: userPrompt},
}
for _, image := range cleanImages {
userParts = append(userParts, contentPart{
Type: "image_url",
ImageURL: &imageURLPart{URL: image},
})
}
reqBody := chatCompletionsRequest{
Model: model,
Messages: []chatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userParts},
},
Temperature: temperature,
MaxTokens: maxTokens,
}
falseVal := false
reqBody.EnableThinking = &falseVal
zero := 0
reqBody.ThinkingBudget = &zero
reqBody.ResponseFormat = &responseFormatConfig{Type: "json_object"}
data, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
endpoint := baseURL + "/v1/chat/completions"
if strings.HasSuffix(baseURL, "/v1") {
endpoint = baseURL + "/chat/completions"
}
body, statusCode, err := c.doChatCompletionRequest(ctx, endpoint, data)
if err != nil {
return "", &chatCompletionError{StatusCode: 0, Err: err}
}
if statusCode >= 400 {
return "", &chatCompletionError{
StatusCode: statusCode,
Err: fmt.Errorf("openai error status=%d body=%s", statusCode, string(body)),
}
}
var parsed chatCompletionsResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return "", &chatCompletionError{
StatusCode: statusCode,
Err: fmt.Errorf("decode response: %w", err),
}
}
if len(parsed.Choices) == 0 {
return "", &chatCompletionError{
StatusCode: statusCode,
Err: fmt.Errorf("empty response choices"),
}
}
return parsed.Choices[0].Message.Content, nil
}
// chatCompletionError wraps errors from chatCompletion so callers can decide
// retryability based on the upstream HTTP status code. StatusCode == 0 means
// the request never reached the server (transport failure).
type chatCompletionError struct {
StatusCode int
Err error
}
func (e *chatCompletionError) Error() string {
if e.Err == nil {
return "chat completion error"
}
return e.Err.Error()
}
func (e *chatCompletionError) Unwrap() error {
return e.Err
}
// isRetryableModerationErr reports whether a moderation caller should retry
// after the given chatCompletion error. Context cancellation and non-429 4xx
// responses are not retryable; transport, 429, 5xx, and parsing errors are.
func isRetryableModerationErr(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
var ce *chatCompletionError
if errors.As(err, &ce) {
if ce.StatusCode >= 400 && ce.StatusCode != http.StatusTooManyRequests && ce.StatusCode < 500 {
return false
}
}
return true
}
func (c *clientImpl) doChatCompletionRequest(ctx context.Context, endpoint string, data []byte) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return nil, 0, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("request openai: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, fmt.Errorf("read response: %w", err)
}
return body, resp.StatusCode, nil
}
func sleepWithBackoff(ctx context.Context, attempt int) error {
delay := initialRetryBackoff * time.Duration(1<<attempt)
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return fmt.Errorf("request cancelled: %w", ctx.Err())
case <-timer.C:
return nil
}
}
// moderateWithRetry runs a single moderation request up to maxModerationResultRetries
// times, retrying only on retryable chatCompletion errors. The underlying chatCompletion
// no longer retries on its own, so the total number of upstream calls is bounded by
// maxModerationResultRetries (previously 3x3=9).
func (c *clientImpl) moderateWithRetry(
ctx context.Context,
systemPrompt, userPrompt string,
images []string,
) (*ModerationResponse, error) {
var lastErr error
for attempt := 0; attempt < maxModerationResultRetries; attempt++ {
replyText, err := c.chatCompletion(ctx, c.cfg.ModerationModel, systemPrompt, userPrompt, images, 0.1, 220)
if err != nil {
lastErr = err
} else {
resp, parseErr := parseModerationReply(replyText)
if parseErr != nil {
lastErr = parseErr
} else {
return resp, nil
}
}
if attempt == maxModerationResultRetries-1 {
break
}
if !isRetryableModerationErr(lastErr) {
break
}
if sleepErr := sleepWithBackoff(ctx, attempt); sleepErr != nil {
return nil, sleepErr
}
}
return nil, lastErr
}
func parseModerationReply(replyText string) (*ModerationResponse, error) {
parsed := struct {
Result string `json:"result"`
Reason string `json:"reason"`
}{}
if err := json.Unmarshal([]byte(extractJSONObject(replyText)), &parsed); err != nil {
return nil, fmt.Errorf("failed to parse moderation result: %w", err)
}
result := ModerationResultPass
switch parsed.Result {
case "review":
result = ModerationResultReview
case "block":
result = ModerationResultBlock
}
return &ModerationResponse{
Result: result,
Reason: parsed.Reason,
}, nil
}
func normalizeImageURLs(images []string) []string {
clean := make([]string, 0, len(images))
for _, image := range images {
trimmed := strings.TrimSpace(image)
if trimmed == "" {
continue
}
clean = append(clean, trimmed)
}
return clean
}
func extractJSONObject(raw string) string {
text := strings.TrimSpace(raw)
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start >= 0 && end > start {
return text[start : end+1]
}
return text
}
func (c *clientImpl) maxImagesPerModerationRequest() int {
configured := c.cfg.ModerationMaxImagesPerRequest
if configured <= 0 {
return defaultMaxImagesPerModerationRequest
}
if configured > maxAllowedImagesPerModerationRequest {
return maxAllowedImagesPerModerationRequest
}
return configured
}
func (c *clientImpl) optimizeImagesForModeration(ctx context.Context, images []string) []string {
if len(images) == 0 {
return images
}
optimized := make([]string, 0, len(images))
for _, imageURL := range images {
optimized = append(optimized, c.tryCompressImageForModeration(ctx, imageURL))
}
return optimized
}
func (c *clientImpl) tryCompressImageForModeration(ctx context.Context, imageURL string) string {
parsed, err := url.Parse(imageURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return imageURL
}
if err := netutil.ValidateURL(imageURL); err != nil {
return imageURL
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if err != nil {
return imageURL
}
resp, err := c.httpClient.Do(req)
if err != nil {
return imageURL
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return imageURL
}
if !strings.HasPrefix(strings.ToLower(resp.Header.Get("Content-Type")), "image/") {
return imageURL
}
originBytes, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadImageBytes))
if err != nil || len(originBytes) == 0 {
return imageURL
}
srcImg, _, err := image.Decode(bytes.NewReader(originBytes))
if err != nil {
return imageURL
}
dstImg := resizeIfNeeded(srcImg, maxModerationImageSide)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, dstImg, &jpeg.Options{Quality: compressedJPEGQuality}); err != nil {
return imageURL
}
compressed := buf.Bytes()
if len(compressed) == 0 || len(compressed) > maxCompressedPayloadBytes {
return imageURL
}
if len(compressed) >= int(float64(len(originBytes))*0.95) {
return imageURL
}
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(compressed)
}
func resizeIfNeeded(src image.Image, maxSide int) image.Image {
bounds := src.Bounds()
w := bounds.Dx()
h := bounds.Dy()
if w <= 0 || h <= 0 || max(w, h) <= maxSide {
return src
}
newW, newH := w, h
if w >= h {
newW = maxSide
newH = int(float64(h) * (float64(maxSide) / float64(w)))
} else {
newH = maxSide
newW = int(float64(w) * (float64(maxSide) / float64(h)))
}
if newW < 1 {
newW = 1
}
if newH < 1 {
newH = 1
}
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, bounds, xdraw.Over, nil)
return dst
}