package service import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "cmautobuy/admin/model" ) const maxAIModelResponseBytes = 64 << 10 type AIModelCandidate struct { ID string `json:"id"` Label string `json:"label"` Options map[string]string `json:"options"` } type AIModelMatchRequest struct { ProductTitle string `json:"product_title"` SourceSpec string `json:"source_spec"` Candidates []AIModelCandidate `json:"candidates"` } type AIModelMatchResponse struct { Conclusion string `json:"conclusion"` CandidateID string `json:"candidate_id"` ConfidenceBPS int `json:"confidence_bps"` Reason string `json:"reason"` ConflictDimensions []string `json:"conflict_dimensions"` MissingDimensions []string `json:"missing_dimensions"` } type AIModelClient interface { Match(context.Context, model.AIProviderConfig, string, AIModelMatchRequest) (AIModelMatchResponse, error) } type aiWhitelistedSelectionStatus string const ( aiSelectionMatched aiWhitelistedSelectionStatus = "matched" aiSelectionUncertain aiWhitelistedSelectionStatus = "uncertain" aiSelectionConflict aiWhitelistedSelectionStatus = "conflict" aiSelectionFailed aiWhitelistedSelectionStatus = "failed" aiSelectionCandidateRejected aiWhitelistedSelectionStatus = "candidate_rejected" aiSelectionDimensionsUnresolved aiWhitelistedSelectionStatus = "dimensions_unresolved" aiSelectionBelowThreshold aiWhitelistedSelectionStatus = "below_threshold" ) // aiWhitelistedSelection 是 SYB 批量匹配和 Client 真机匹配共用的模型安全结论。 // 业务编排可以把“不确定”显示成不同文案,但不能绕过这里的候选、阈值和维度门禁。 type aiWhitelistedSelection struct { Status aiWhitelistedSelectionStatus Response AIModelMatchResponse Reason string ConfidenceSet bool ModelCalled bool } func selectAIWhitelistedCandidate(ctx context.Context, snapshot AIMatchSnapshot, request AIModelMatchRequest) aiWhitelistedSelection { if snapshot.Client == nil || snapshot.Secret == "" || snapshot.Provider.ProviderID == "" { return aiWhitelistedSelection{Status: aiSelectionFailed, Reason: "AI 服务商运行快照不可用"} } response, err := snapshot.Client.Match(ctx, snapshot.Provider, snapshot.Secret, request) if err != nil { reason := safeAIError(err) if snapshot.Secret != "" { reason = strings.ReplaceAll(reason, snapshot.Secret, "[REDACTED]") } return aiWhitelistedSelection{Status: aiSelectionFailed, Reason: reason, ModelCalled: true} } result := aiWhitelistedSelection{Response: response, Reason: response.Reason, ConfidenceSet: true, ModelCalled: true} if err := validateAIModelMatchResponse(response); err != nil { result.Status, result.Reason, result.ConfidenceSet = aiSelectionFailed, err.Error(), false return result } if response.Conclusion == "uncertain" { result.Status = aiSelectionUncertain return result } if response.Conclusion == "conflict" { result.Status = aiSelectionConflict return result } allowed := false for _, candidate := range request.Candidates { if candidate.ID == response.CandidateID { allowed = true break } } if !allowed { result.Status, result.Reason = aiSelectionCandidateRejected, "模型返回了不在候选白名单中的编号" return result } if len(response.ConflictDimensions) > 0 || len(response.MissingDimensions) > 0 { result.Status, result.Reason = aiSelectionDimensionsUnresolved, "模型报告仍有冲突或缺失维度" return result } if response.ConfidenceBPS < snapshot.Provider.ConfidenceThresholdBPS { result.Status, result.Reason = aiSelectionBelowThreshold, "模型置信度低于管理员设置的自动选择阈值" return result } result.Status = aiSelectionMatched return result } type OpenAICompatibleModelClient struct{ doer AIHTTPDoer } func NewOpenAICompatibleModelClient(doer AIHTTPDoer) *OpenAICompatibleModelClient { return &OpenAICompatibleModelClient{doer: doer} } func (c *OpenAICompatibleModelClient) Match(ctx context.Context, provider model.AIProviderConfig, secret string, input AIModelMatchRequest) (AIModelMatchResponse, error) { if c == nil || c.doer == nil { return AIModelMatchResponse{}, fmt.Errorf("AI 模型客户端未初始化") } inputJSON, err := json.Marshal(input) if err != nil { return AIModelMatchResponse{}, fmt.Errorf("准备 AI 规格候选失败") } system := `你是商品规格候选选择器。只能从 candidates 的 id 中选择,不能生成新候选。` + `只返回 JSON:conclusion(match/uncertain/conflict)、candidate_id、confidence_bps(0-10000)、` + `reason、conflict_dimensions、missing_dimensions。信息不足时 conclusion 必须是 uncertain。` payload, err := json.Marshal(map[string]any{ "model": provider.Model, "messages": []map[string]string{{"role": "system", "content": system}, {"role": "user", "content": string(inputJSON)}}, "temperature": 0, "max_tokens": 300, "response_format": map[string]string{"type": "json_object"}, }) if err != nil { return AIModelMatchResponse{}, fmt.Errorf("准备 AI 模型请求失败") } request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(provider.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload)) if err != nil { return AIModelMatchResponse{}, fmt.Errorf("准备 AI 模型请求失败") } request.Header.Set("Authorization", "Bearer "+secret) request.Header.Set("Content-Type", "application/json") response, err := c.doer.Do(request) if err != nil { return AIModelMatchResponse{}, fmt.Errorf("AI 模型请求失败") } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxAIModelResponseBytes)) return AIModelMatchResponse{}, fmt.Errorf("AI 模型返回 HTTP %d", response.StatusCode) } raw, err := io.ReadAll(io.LimitReader(response.Body, maxAIModelResponseBytes+1)) if err != nil || len(raw) > maxAIModelResponseBytes { return AIModelMatchResponse{}, fmt.Errorf("AI 模型响应无法读取或过大") } var outer struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.Unmarshal(raw, &outer); err != nil || len(outer.Choices) == 0 { return AIModelMatchResponse{}, fmt.Errorf("AI 模型响应格式不正确") } decoder := json.NewDecoder(strings.NewReader(outer.Choices[0].Message.Content)) decoder.DisallowUnknownFields() var result AIModelMatchResponse if err := decoder.Decode(&result); err != nil { return AIModelMatchResponse{}, fmt.Errorf("AI 模型结论不是有效 JSON") } var extra any if decoder.Decode(&extra) != io.EOF { return AIModelMatchResponse{}, fmt.Errorf("AI 模型结论包含多余内容") } return result, nil }