2026-03-09 21:28:58 +08:00
|
|
|
|
package openai
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
|
"encoding/json"
|
2026-07-08 01:47:48 +08:00
|
|
|
|
"errors"
|
2026-03-09 21:28:58 +08:00
|
|
|
|
"fmt"
|
|
|
|
|
|
"image"
|
|
|
|
|
|
_ "image/gif"
|
|
|
|
|
|
"image/jpeg"
|
|
|
|
|
|
_ "image/png"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/url"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
2026-04-30 12:26:25 +08:00
|
|
|
|
"with_you/internal/pkg/netutil"
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
xdraw "golang.org/x/image/draw"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
const moderationSystemPrompt = `你是中文社区的内容审核助手,负责对"帖子标题、正文、配图"做联合审核。原则是**宁可放过,不可误伤**:只有明确违法违规内容才标记 review 交人工复核,其余一律 pass。请只输出指定JSON。
|
|
|
|
|
|
|
|
|
|
|
|
核心原则:
|
|
|
|
|
|
- 除明确违法违规外,不要拦截或送审正常内容,包括尖锐吐槽、玩梗、二创、争论;
|
|
|
|
|
|
- 判定从严,倾向放行:宁可放过十个可疑,不可误伤一个正常。
|
2026-04-11 04:23:24 +08:00
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
只有命中**明确违法违规**时才输出 result=review:
|
|
|
|
|
|
A. 违法犯罪:明确宣传违法犯罪行为、教唆或指导他人犯罪(如毒品、枪支、诈骗教程)。
|
|
|
|
|
|
B. 真实暴力威胁:针对具体个人或群体的实质性暴力威胁、人身伤害恐吓。
|
|
|
|
|
|
C. 色情低俗:明确色情、露骨性描写或诱导性内容;正常人体艺术、二次创作用感形象不在此列。
|
|
|
|
|
|
D. 诈骗引流:明确的诈骗信息、钓鱼链接、黑产引流。
|
|
|
|
|
|
E. 未成年保护:涉及未成年人的色情或剥削内容。
|
|
|
|
|
|
F. 开盒/人肉:故意公开他人可识别隐私信息(身份证号、住址、联系方式、定位轨迹)并伴有曝光意图。
|
|
|
|
|
|
G. 极端仇恨:鼓动对特定族裔/宗教/残障群体的仇恨与暴力,含纳粹等极端符号。
|
|
|
|
|
|
|
|
|
|
|
|
以下情形即使存在也**一律 pass**(不送审、不拦截):
|
|
|
|
|
|
- 正常玩梗、表情包、谐音梗、二次创作、无恶意的吐槽;
|
|
|
|
|
|
- 非定向、轻度口语化吐槽、玩笑、自嘲、朋友间互动;
|
|
|
|
|
|
- 对社会事件/作品的理性讨论、观点争论(即使语气尖锐、用词激烈);
|
|
|
|
|
|
- 一般性粗口、调侃、讽刺,未煽动对立或人身攻击的;
|
|
|
|
|
|
- 对政治人物/社会热点的评论、争议性观点表达;
|
|
|
|
|
|
- 含义模糊但无明显违法意图的内容。
|
2026-04-11 04:23:24 +08:00
|
|
|
|
|
|
|
|
|
|
reason 要求:
|
2026-07-08 01:47:48 +08:00
|
|
|
|
- result=review 时:中文10-30字,说明疑似违法点;
|
2026-04-11 04:23:24 +08:00
|
|
|
|
- result=pass 时:reason 为空字符串。
|
|
|
|
|
|
|
|
|
|
|
|
输出格式(严格):
|
|
|
|
|
|
仅输出一行JSON对象,不要Markdown,不要额外解释:
|
2026-07-08 01:47:48 +08:00
|
|
|
|
{"result": "pass"/"review", "reason": "..."}`
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
defaultMaxImagesPerModerationRequest = 1
|
2026-07-08 01:47:48 +08:00
|
|
|
|
maxAllowedImagesPerModerationRequest = 4
|
2026-03-09 21:28:58 +08:00
|
|
|
|
maxModerationResultRetries = 3
|
|
|
|
|
|
initialRetryBackoff = 500 * time.Millisecond
|
|
|
|
|
|
maxDownloadImageBytes = 10 * 1024 * 1024
|
|
|
|
|
|
maxModerationImageSide = 1280
|
|
|
|
|
|
compressedJPEGQuality = 72
|
|
|
|
|
|
maxCompressedPayloadBytes = 1536 * 1024
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-11 04:23:24 +08:00
|
|
|
|
type ModerationResult string
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
ModerationResultPass ModerationResult = "pass"
|
|
|
|
|
|
ModerationResultReview ModerationResult = "review"
|
|
|
|
|
|
ModerationResultBlock ModerationResult = "block"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type ModerationResponse struct {
|
|
|
|
|
|
Result ModerationResult `json:"result"`
|
|
|
|
|
|
Reason string `json:"reason"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
type Client interface {
|
|
|
|
|
|
IsEnabled() bool
|
|
|
|
|
|
Config() Config
|
2026-04-11 04:23:24 +08:00
|
|
|
|
ModeratePost(ctx context.Context, title, content string, images []string) (*ModerationResponse, error)
|
|
|
|
|
|
ModerateComment(ctx context.Context, content string, images []string) (*ModerationResponse, error)
|
2026-04-15 11:50:47 +08:00
|
|
|
|
ModerateImage(ctx context.Context, imageURL string) (*ModerationResponse, error)
|
|
|
|
|
|
ModerateBio(ctx context.Context, bio string) (*ModerationResponse, error)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type clientImpl struct {
|
|
|
|
|
|
cfg Config
|
|
|
|
|
|
httpClient *http.Client
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func NewClient(cfg Config) Client {
|
|
|
|
|
|
timeout := cfg.RequestTimeoutSeconds
|
|
|
|
|
|
if timeout <= 0 {
|
|
|
|
|
|
timeout = 30
|
|
|
|
|
|
}
|
|
|
|
|
|
return &clientImpl{
|
2026-04-30 12:26:25 +08:00
|
|
|
|
cfg: cfg,
|
|
|
|
|
|
httpClient: netutil.NewSafeHTTPClient(time.Duration(timeout) * time.Second),
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *clientImpl) IsEnabled() bool {
|
|
|
|
|
|
return c.cfg.Enabled && c.cfg.APIKey != "" && c.cfg.BaseURL != ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *clientImpl) Config() Config {
|
|
|
|
|
|
return c.cfg
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 04:23:24 +08:00
|
|
|
|
func (c *clientImpl) ModeratePost(ctx context.Context, title, content string, images []string) (*ModerationResponse, error) {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if !c.IsEnabled() {
|
2026-04-11 04:23:24 +08:00
|
|
|
|
return &ModerationResponse{Result: ModerationResultPass}, nil
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-26 18:14:16 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 04:23:24 +08:00
|
|
|
|
func (c *clientImpl) ModerateComment(ctx context.Context, content string, images []string) (*ModerationResponse, error) {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if !c.IsEnabled() {
|
2026-04-11 04:23:24 +08:00
|
|
|
|
return &ModerationResponse{Result: ModerationResultPass}, nil
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-26 18:14:16 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
const imageModerationSystemPrompt = `你是图片内容审核助手,负责审核用户头像和背景图片。原则是**宁可放过,不可误伤**:只有明确违法违规的图片才标记 review 交人工复核,其余一律 pass。请只输出指定JSON。
|
2026-04-15 11:50:47 +08:00
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
核心原则:
|
|
|
|
|
|
- 除明确违法违规外,不要拦截或送审正常图片;
|
|
|
|
|
|
- 判定从严,倾向放行:宁可放过十张可疑,不可误伤一张正常。
|
2026-04-15 11:50:47 +08:00
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
只有命中**明确违法违规**时才输出 result=review:
|
|
|
|
|
|
- 色情低俗:明确色情、露骨性描写或诱导性图片;正常人体艺术、二次创作用感形象不在此列;
|
|
|
|
|
|
- 真实暴力/血腥:明确展示严重暴力、凶杀、虐待等违法犯罪画面;
|
|
|
|
|
|
- 违法犯罪相关:明确涉及毒品、枪支、诈骗、黑产等内容;
|
|
|
|
|
|
- 未成年保护:涉及未成年人的色情或剥削图片;
|
|
|
|
|
|
- 诈骗引流:明确的诈骗二维码、钓鱼链接、黑产广告;
|
|
|
|
|
|
- 极端仇恨:纳粹等极端仇恨符号或标志。
|
2026-04-15 11:50:47 +08:00
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
以下图片即使存在也**一律 pass**(不送审、不拦截):
|
|
|
|
|
|
- 正常风景、人物、动物、动漫等图片;
|
|
|
|
|
|
- 表情包、梗图、二次创作;
|
|
|
|
|
|
- 正常的艺术创作、摄影作品;
|
|
|
|
|
|
- 含义模糊但无明显违法意图的图片。
|
|
|
|
|
|
|
|
|
|
|
|
reason 要求:
|
|
|
|
|
|
- result=review 时:中文10-30字,说明疑似违法点;
|
|
|
|
|
|
- result=pass 时:reason 为空字符串。
|
2026-04-15 11:50:47 +08:00
|
|
|
|
|
|
|
|
|
|
输出格式(严格):
|
|
|
|
|
|
仅输出一行JSON对象,不要Markdown,不要额外解释:
|
2026-07-08 01:47:48 +08:00
|
|
|
|
{"result": "pass"/"review", "reason": "..."}`
|
2026-04-15 11:50:47 +08:00
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
const bioModerationSystemPrompt = `你是用户个性签名内容审核助手,负责审核用户个人简介/签名。原则是**宁可放过,不可误伤**:只有明确违法违规内容才标记 review 交人工复核,其余一律 pass。请只输出指定JSON。
|
2026-04-15 11:50:47 +08:00
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
核心原则:
|
|
|
|
|
|
- 除明确违法违规外,不要拦截或送审正常签名;
|
|
|
|
|
|
- 判定从严,倾向放行:宁可放过十个可疑,不可误伤一个正常。
|
2026-04-15 11:50:47 +08:00
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
只有命中**明确违法违规**时才输出 result=review:
|
|
|
|
|
|
- 明确违法犯罪、暴力威胁内容;
|
|
|
|
|
|
- 色情低俗、露骨性暗示描述;
|
|
|
|
|
|
- 诈骗引流、黑产广告;
|
|
|
|
|
|
- 恶意公开他人可识别隐私信息;
|
|
|
|
|
|
- 涉及未成年人的色情或剥削内容。
|
2026-04-15 11:50:47 +08:00
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
以下签名即使存在也**一律 pass**(不送审、不拦截):
|
|
|
|
|
|
- 正常的自我介绍、心情表达;
|
|
|
|
|
|
- 玩梗、幽默、吐槽、谐音梗;
|
|
|
|
|
|
- 引用、歌词、诗句等;
|
|
|
|
|
|
- 理性的观点表达、对社会事件的评论;
|
|
|
|
|
|
- 含义模糊但无明显违法意图的内容。
|
|
|
|
|
|
|
|
|
|
|
|
reason 要求:
|
|
|
|
|
|
- result=review 时:中文10-30字,说明疑似违法点;
|
|
|
|
|
|
- result=pass 时:reason 为空字符串。
|
2026-04-15 11:50:47 +08:00
|
|
|
|
|
|
|
|
|
|
输出格式(严格):
|
|
|
|
|
|
仅输出一行JSON对象,不要Markdown,不要额外解释:
|
2026-07-08 01:47:48 +08:00
|
|
|
|
{"result": "pass"/"review", "reason": "..."}`
|
2026-04-15 11:50:47 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
resp, err := c.moderateWithRetry(ctx, imageModerationSystemPrompt, userPrompt, optimizedImages)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("image moderation failed after %d attempts: %w", maxModerationResultRetries, err)
|
2026-04-15 11:50:47 +08:00
|
|
|
|
}
|
2026-07-08 01:47:48 +08:00
|
|
|
|
return resp, nil
|
2026-04-15 11:50:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-07-08 01:47:48 +08:00
|
|
|
|
resp, err := c.moderateWithRetry(ctx, systemPrompt, userPrompt, nil)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("moderation failed after %d attempts: %w", maxModerationResultRetries, err)
|
2026-04-15 11:50:47 +08:00
|
|
|
|
}
|
2026-07-08 01:47:48 +08:00
|
|
|
|
return resp, nil
|
2026-04-15 11:50:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 04:23:24 +08:00
|
|
|
|
func (c *clientImpl) moderateContentInBatches(ctx context.Context, contentPrompt string, images []string) (*ModerationResponse, error) {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 04:23:24 +08:00
|
|
|
|
resp, err := c.moderateSingleBatch(ctx, contentPrompt, batchImages, i+1, totalBatches)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
2026-04-11 04:23:24 +08:00
|
|
|
|
return nil, err
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-04-11 04:23:24 +08:00
|
|
|
|
if resp.Result != ModerationResultPass {
|
|
|
|
|
|
if totalBatches > 1 && strings.TrimSpace(resp.Reason) != "" {
|
|
|
|
|
|
resp.Reason = fmt.Sprintf("第%d/%d批图片未通过:%s", i+1, totalBatches, resp.Reason)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-04-11 04:23:24 +08:00
|
|
|
|
return resp, nil
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 04:23:24 +08:00
|
|
|
|
return &ModerationResponse{Result: ModerationResultPass}, nil
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *clientImpl) moderateSingleBatch(
|
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
|
contentPrompt string,
|
|
|
|
|
|
images []string,
|
|
|
|
|
|
batchNo, totalBatches int,
|
2026-04-11 04:23:24 +08:00
|
|
|
|
) (*ModerationResponse, error) {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
userPrompt := fmt.Sprintf(
|
|
|
|
|
|
"%s\n图片批次:%d/%d(本次仅提供当前批次图片)",
|
|
|
|
|
|
contentPrompt,
|
|
|
|
|
|
batchNo,
|
|
|
|
|
|
totalBatches,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
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
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type chatCompletionsRequest struct {
|
2026-03-12 08:38:14 +08:00
|
|
|
|
Model string `json:"model"`
|
|
|
|
|
|
Messages []chatMessage `json:"messages"`
|
|
|
|
|
|
Temperature float64 `json:"temperature,omitempty"`
|
|
|
|
|
|
MaxTokens int `json:"max_tokens,omitempty"`
|
2026-04-11 04:23:24 +08:00
|
|
|
|
EnableThinking *bool `json:"enable_thinking,omitempty"`
|
|
|
|
|
|
ThinkingBudget *int `json:"thinking_budget,omitempty"`
|
|
|
|
|
|
ResponseFormat *responseFormatConfig `json:"response_format,omitempty"`
|
2026-03-12 08:38:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type responseFormatConfig struct {
|
2026-04-11 04:23:24 +08:00
|
|
|
|
Type string `json:"type"`
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type chatMessage struct {
|
2026-03-30 04:49:35 +08:00
|
|
|
|
Role string `json:"role"`
|
|
|
|
|
|
Content any `json:"content"`
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
2026-03-12 08:38:14 +08:00
|
|
|
|
falseVal := false
|
|
|
|
|
|
reqBody.EnableThinking = &falseVal
|
|
|
|
|
|
zero := 0
|
|
|
|
|
|
reqBody.ThinkingBudget = &zero
|
|
|
|
|
|
reqBody.ResponseFormat = &responseFormatConfig{Type: "json_object"}
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
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)),
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-07-08 01:47:48 +08:00
|
|
|
|
}
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
var parsed chatCompletionsResponse
|
|
|
|
|
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
|
|
|
|
return "", &chatCompletionError{
|
|
|
|
|
|
StatusCode: statusCode,
|
|
|
|
|
|
Err: fmt.Errorf("decode response: %w", err),
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-07-08 01:47:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
if len(parsed.Choices) == 0 {
|
|
|
|
|
|
return "", &chatCompletionError{
|
|
|
|
|
|
StatusCode: statusCode,
|
|
|
|
|
|
Err: fmt.Errorf("empty response choices"),
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-08 01:47:48 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
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
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 01:47:48 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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 {
|
2026-07-08 01:47:48 +08:00
|
|
|
|
configured := c.cfg.ModerationMaxImagesPerRequest
|
|
|
|
|
|
if configured <= 0 {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return defaultMaxImagesPerModerationRequest
|
|
|
|
|
|
}
|
2026-07-08 01:47:48 +08:00
|
|
|
|
if configured > maxAllowedImagesPerModerationRequest {
|
|
|
|
|
|
return maxAllowedImagesPerModerationRequest
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-07-08 01:47:48 +08:00
|
|
|
|
return configured
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-04-30 12:26:25 +08:00
|
|
|
|
if err := netutil.ValidateURL(imageURL); err != nil {
|
|
|
|
|
|
return imageURL
|
|
|
|
|
|
}
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|