Files
goauto/server/app/goauto/aimatching/service.go
T

453 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package aimatching
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
const (
settingID = uint64(1)
defaultTimeoutSecs = 15
maxTimeoutSecs = 600
minTimeoutSecs = 3
maxSettingText = 2048
defaultAutoConfirmMinConfidence = 0.9
)
// MaxProviderTimeout is also the total budget used by composite synchronous
// AI operations. This keeps their HTTP response inside the Admin and API
// transport windows even when an operation needs more than one provider call.
const MaxProviderTimeout = 600 * time.Second
// Service owns the internal AI Provider configuration. The API key exception
// is deliberately narrow: it is plain text only in the dedicated settings
// table and is returned only by the administrator settings handler.
type Service struct {
DB *gorm.DB
HTTPClient *http.Client
ProviderFailureLogger func(ProviderFailureDiagnostic)
}
// ProviderFailureDiagnostic deliberately contains no URL, model, prompt,
// candidates, response body or credential. It is safe for operational logs.
type ProviderFailureDiagnostic struct {
CallID string
Operation string
Kind string
StatusCode int
Duration time.Duration
}
func NewService(db *gorm.DB) *Service {
return &Service{DB: db, HTTPClient: secureHTTPClient()}
}
func secureHTTPClient() *http.Client {
return &http.Client{
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse },
}
}
func (s *Service) Settings(ctx context.Context) (SettingsView, error) {
setting, err := s.setting(ctx)
if errors.Is(err, gorm.ErrRecordNotFound) {
return SettingsView{Enabled: false, Provider: ProviderOpenAICompatible, TimeoutSeconds: defaultTimeoutSecs, AutoConfirmMinConfidence: defaultAutoConfirmMinConfidence}, nil
}
if err != nil {
return SettingsView{}, err
}
return settingView(setting), nil
}
func (s *Service) SaveSettings(ctx context.Context, request SaveSettingsRequest, operatorID uint64) (SettingsView, error) {
if operatorID == 0 {
return SettingsView{}, fail(CodeInvalidSetting, "无法识别当前管理员")
}
request, err := validateSettings(request)
if err != nil {
return SettingsView{}, err
}
var result models.AIMatchingSetting
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current models.AIMatchingSetting
err := tx.First(&current, settingID).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
current = models.AIMatchingSetting{ID: settingID, Provider: ProviderOpenAICompatible, BaseURL: "", Model: "", APIKey: "", TimeoutSeconds: defaultTimeoutSecs, AutoConfirmMinConfidence: defaultAutoConfirmMinConfidence}
}
if key := strings.TrimSpace(request.APIKey); key != "" {
current.APIKey = key
}
if request.Enabled && strings.TrimSpace(current.APIKey) == "" {
return fail(CodeInvalidSetting, "启用 AI 匹配前请填写 API Key")
}
current.Enabled = request.Enabled
current.Provider = ProviderOpenAICompatible
current.BaseURL = request.BaseURL
current.Model = request.Model
current.TimeoutSeconds = request.TimeoutSeconds
if request.AutoConfirmMinConfidence != nil {
current.AutoConfirmMinConfidence = *request.AutoConfirmMinConfidence
}
current.UpdatedBy = &operatorID
if errors.Is(err, gorm.ErrRecordNotFound) {
if createErr := tx.Create(&current).Error; createErr != nil {
return createErr
}
} else if saveErr := tx.Save(&current).Error; saveErr != nil {
return saveErr
}
result = current
return nil
})
if err != nil {
return SettingsView{}, err
}
return settingView(result), nil
}
// TestConnection checks the currently saved provider endpoint. It does not log
// or return the credential, response body, or provider URL.
func (s *Service) TestConnection(ctx context.Context) error {
setting, apiKey, err := s.activeSetting(ctx)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(setting.TimeoutSeconds)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint(setting.BaseURL, "models"), nil)
if err != nil {
return fail(CodeInvalidSetting, "AI 服务地址无效")
}
req.Header.Set("Authorization", "Bearer "+apiKey)
response, err := s.httpClient().Do(req)
if err != nil {
return &Error{Code: CodeProviderUnavailable, Message: "无法连接 AI 服务,请检查地址、模型和网络", Cause: err}
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return fail(CodeProviderUnavailable, "AI 服务连接失败,请检查地址和 API Key")
}
return nil
}
// Resolve runs exact deterministic matching first and uses the configured
// OpenAI-compatible provider only when no unique normalized choice exists.
func (s *Service) Resolve(ctx context.Context, request MatchRequest) (MatchResult, error) {
request = sanitizeRequest(request)
if result, ok := DeterministicMatch(request); ok {
return result, nil
}
setting, apiKey, err := s.activeSetting(ctx)
if err != nil {
return MatchResult{}, err
}
payload := openAIChatRequest{Model: setting.Model, Temperature: 0, Messages: []openAIMessage{
{Role: "system", Content: "你只负责在给定的 PDD 可选规格中选择完全一致的原始标签。不得猜测、不得改写候选值。只返回 JSON:{\"color\":\"候选原文或空\",\"size\":\"候选原文或空\",\"reason\":\"简短原因\",\"confidence\":0到1}。没有唯一可靠结果时对应字段为空。"},
{Role: "user", Content: matchPrompt(request)},
}}
body, err := json.Marshal(payload)
if err != nil {
return MatchResult{}, &Error{Code: CodeProviderUnavailable, Message: "AI 匹配请求生成失败", Cause: err}
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(setting.TimeoutSeconds)*time.Second)
defer cancel()
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint(setting.BaseURL, "chat/completions"), bytes.NewReader(body))
if err != nil {
return MatchResult{}, fail(CodeInvalidSetting, "AI 服务地址无效")
}
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
httpRequest.Header.Set("Content-Type", "application/json")
response, err := s.httpClient().Do(httpRequest)
if err != nil {
return MatchResult{}, &Error{Code: CodeProviderUnavailable, Message: "AI 匹配服务暂时不可用", Cause: err}
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, 1<<20)
responseBody, readErr := io.ReadAll(limited)
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return MatchResult{}, fail(CodeProviderUnavailable, "AI 匹配服务暂时不可用")
}
choice, err := parseProviderChoice(responseBody)
if err != nil {
return MatchResult{}, fail(CodeNoMatch, "没有找到可采购的 PDD 颜色或尺码")
}
if !validChoice(request.TargetColor, choice.Color, request.Colors) || !validChoice(request.TargetSize, choice.Size, request.Sizes) {
return MatchResult{}, fail(CodeNoMatch, "没有找到可采购的 PDD 颜色或尺码")
}
result := matchResult(request, SourceAI, ProviderOpenAICompatible, setting.Model, choice.Color, choice.Size, safeReason(choice.Reason))
if choice.Confidence != nil && *choice.Confidence >= 0 && *choice.Confidence <= 1 {
result.Decision.Confidence = choice.Confidence
}
return result, nil
}
// ResolveSYBSpec parses one SYB productSpec into the linked Shopee product's
// exact color/size labels. Unlike Resolve, this operation does not map to PDD:
// every non-empty answer must be an exact member of the supplied Shopee set.
func (s *Service) ResolveSYBSpec(ctx context.Context, request SYBSpecParseRequest) (SYBSpecParseResult, error) {
request.ProductSpec = strings.TrimSpace(request.ProductSpec)
request.Colors = usableCandidates(request.Colors)
request.Sizes = usableCandidates(request.Sizes)
if request.ProductSpec == "" || (len(request.Colors) == 0 && len(request.Sizes) == 0) {
return SYBSpecParseResult{}, fail(CodeNoMatch, "SYB 采购规格缺少可判断的原文或蝦皮候选")
}
setting, apiKey, err := s.activeSetting(ctx)
if err != nil {
return SYBSpecParseResult{}, err
}
payload := openAIChatRequest{Model: setting.Model, Temperature: 0, Messages: []openAIMessage{
{Role: "system", Content: "你只负责把一条 SYB 商品规格原文解析成给定蝦皮候选中的原始颜色和尺码。不得猜测、不得改写候选、不得返回候选外文本。只返回 JSON:{\"color\":\"颜色候选原文或空\",\"size\":\"尺码候选原文或空\",\"reason\":\"简短原因\",\"confidence\":0到1}。提供了某角色候选时必须唯一可靠地选择一个,否则对应字段留空。"},
{Role: "user", Content: sybSpecParsePrompt(request)},
}}
body, err := json.Marshal(payload)
if err != nil {
return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析请求生成失败", Cause: err}
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(setting.TimeoutSeconds)*time.Second)
defer cancel()
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint(setting.BaseURL, "chat/completions"), bytes.NewReader(body))
if err != nil {
return SYBSpecParseResult{}, fail(CodeInvalidSetting, "AI 服务地址无效")
}
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
httpRequest.Header.Set("Content-Type", "application/json")
response, err := s.httpClient().Do(httpRequest)
if err != nil {
return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析服务暂时不可用", Cause: err}
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return SYBSpecParseResult{}, fail(CodeProviderUnavailable, "SYB 规格 AI 解析服务暂时不可用")
}
choice, err := parseProviderChoice(responseBody)
if err != nil || !validClosedChoice(choice.Color, request.Colors) || !validClosedChoice(choice.Size, request.Sizes) {
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 未能在蝦皮候选中唯一解析采购规格")
}
if choice.Confidence == nil || *choice.Confidence < 0 || *choice.Confidence > 1 || strings.TrimSpace(choice.Reason) == "" {
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 解析结果缺少有效置信度或理由")
}
return SYBSpecParseResult{
Color: choice.Color, Size: choice.Size, Provider: ProviderOpenAICompatible,
Model: setting.Model, Reason: safeReason(choice.Reason), Confidence: choice.Confidence,
}, nil
}
func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) {
setting, err := s.setting(ctx)
if errors.Is(err, gorm.ErrRecordNotFound) || !setting.Enabled {
return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未启用")
}
if err != nil {
return models.AIMatchingSetting{}, "", err
}
if strings.TrimSpace(setting.APIKey) == "" {
return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未配置 API Key")
}
return setting, setting.APIKey, nil
}
func (s *Service) setting(ctx context.Context) (models.AIMatchingSetting, error) {
var setting models.AIMatchingSetting
err := s.DB.WithContext(ctx).First(&setting, settingID).Error
return setting, err
}
func validateSettings(request SaveSettingsRequest) (SaveSettingsRequest, error) {
request.BaseURL = strings.TrimRight(strings.TrimSpace(request.BaseURL), "/")
request.Model = strings.TrimSpace(request.Model)
request.APIKey = strings.TrimSpace(request.APIKey)
if request.TimeoutSeconds == 0 {
request.TimeoutSeconds = defaultTimeoutSecs
}
if request.TimeoutSeconds < minTimeoutSecs || request.TimeoutSeconds > maxTimeoutSecs {
return SaveSettingsRequest{}, fail(CodeInvalidSetting, "超时时间必须在 3 到 600 秒之间")
}
if len(request.BaseURL) > maxSettingText || len(request.Model) > 255 || len(request.APIKey) > maxSettingText {
return SaveSettingsRequest{}, fail(CodeInvalidSetting, "AI 设置内容过长")
}
if request.AutoConfirmMinConfidence != nil && (*request.AutoConfirmMinConfidence < 0 || *request.AutoConfirmMinConfidence > 1) {
return SaveSettingsRequest{}, fail(CodeInvalidSetting, "自动确认阈值必须在 0 与 1 之间")
}
if request.Enabled {
if request.BaseURL == "" || request.Model == "" {
return SaveSettingsRequest{}, fail(CodeInvalidSetting, "启用 AI 匹配前请填写服务地址和模型")
}
if _, err := validatedBaseURL(request.BaseURL); err != nil {
return SaveSettingsRequest{}, err
}
} else if request.BaseURL != "" {
if _, err := validatedBaseURL(request.BaseURL); err != nil {
return SaveSettingsRequest{}, err
}
}
return request, nil
}
func validatedBaseURL(raw string) (*url.URL, error) {
parsed, err := url.Parse(raw)
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, fail(CodeInvalidSetting, "AI 服务地址必须是有效的 HTTP 或 HTTPS 地址")
}
return parsed, nil
}
func settingView(setting models.AIMatchingSetting) SettingsView {
timeout := setting.TimeoutSeconds
if timeout == 0 {
timeout = defaultTimeoutSecs
}
return SettingsView{Enabled: setting.Enabled, Provider: ProviderOpenAICompatible, BaseURL: setting.BaseURL, Model: setting.Model, APIKey: setting.APIKey, TimeoutSeconds: timeout, AutoConfirmMinConfidence: setting.AutoConfirmMinConfidence}
}
func (s *Service) httpClient() *http.Client {
if s.HTTPClient == nil {
return secureHTTPClient()
}
return s.HTTPClient
}
func endpoint(base, suffix string) string {
return strings.TrimRight(base, "/") + "/" + suffix
}
func sanitizeRequest(request MatchRequest) MatchRequest {
request.TargetColor, request.TargetSize = strings.TrimSpace(request.TargetColor), strings.TrimSpace(request.TargetSize)
request.Colors = usableCandidates(request.Colors)
request.Sizes = usableCandidates(request.Sizes)
return request
}
func usableCandidates(values []string) []string {
seen := make(map[string]bool, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func validChoice(target, selected string, candidates []string) bool {
if strings.TrimSpace(target) == "" {
return strings.TrimSpace(selected) == ""
}
selected = strings.TrimSpace(selected)
if selected == "" {
return false
}
for _, candidate := range candidates {
if candidate == selected {
return true
}
}
return false
}
func validClosedChoice(selected string, candidates []string) bool {
selected = strings.TrimSpace(selected)
if len(candidates) == 0 {
return selected == ""
}
if selected == "" {
return false
}
for _, candidate := range candidates {
if candidate == selected {
return true
}
}
return false
}
func safeReason(reason string) string {
reason = strings.TrimSpace(reason)
if reason == "" {
return "AI 在可选规格中返回唯一结果"
}
if len([]rune(reason)) > 200 {
return string([]rune(reason)[:200])
}
return reason
}
func matchPrompt(request MatchRequest) string {
payload := struct {
TargetColor string `json:"targetColor,omitempty"`
TargetSize string `json:"targetSize,omitempty"`
Colors []string `json:"pddSelectableColors,omitempty"`
Sizes []string `json:"pddSelectableSizes,omitempty"`
}{request.TargetColor, request.TargetSize, request.Colors, request.Sizes}
raw, _ := json.Marshal(payload)
return string(raw)
}
func sybSpecParsePrompt(request SYBSpecParseRequest) string {
payload := struct {
ProductSpec string `json:"productSpec"`
Colors []string `json:"shopeeColorCandidates,omitempty"`
Sizes []string `json:"shopeeSizeCandidates,omitempty"`
}{request.ProductSpec, request.Colors, request.Sizes}
raw, _ := json.Marshal(payload)
return string(raw)
}
type openAIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type openAIChatRequest struct {
Model string `json:"model"`
Temperature float64 `json:"temperature"`
Messages []openAIMessage `json:"messages"`
}
type providerChoice struct {
Color string `json:"color"`
Size string `json:"size"`
Reason string `json:"reason"`
Confidence *float64 `json:"confidence"`
}
func parseProviderChoice(raw []byte) (providerChoice, error) {
var response struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(raw, &response); err != nil || len(response.Choices) == 0 {
return providerChoice{}, errors.New("invalid provider response")
}
content := strings.TrimSpace(response.Choices[0].Message.Content)
content = strings.TrimPrefix(content, "```json")
content = strings.TrimPrefix(content, "```")
content = strings.TrimSuffix(strings.TrimSpace(content), "```")
var choice providerChoice
if err := json.Unmarshal([]byte(content), &choice); err != nil {
return providerChoice{}, err
}
choice.Color, choice.Size = strings.TrimSpace(choice.Color), strings.TrimSpace(choice.Size)
return choice, nil
}