Files
cmautobuy/admin/service/ai_specmatch_client.go
T

110 lines
4.0 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 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
}