feat: 实现 AI 规格候选匹配与审计 (#201)

This commit is contained in:
chengma
2026-08-14 10:08:32 +08:00
parent 2f51126e1d
commit aac289a4d3
15 changed files with 1200 additions and 25 deletions
+109
View File
@@ -0,0 +1,109 @@
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 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
}