2026-08-14 10:08:32 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 17:20:07 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 10:08:32 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|