Files
cmautobuy/admin/service/ai_specmatch_client.go
T

182 lines
6.9 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 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
}