// Package vision 调用独立部署的 FashionSigLIP 视觉服务,把图片编码成归一化向量。 // // 设计要点(对应规格 docs/superpowers/specs/2026-10-12-fashionclip-siglip-vision-service-design.md §6/§11): // - 服务不可用、超时、维度不符一律返回 ErrUnavailable,调用方据此「不写向量」而非中断入库; // - 连续失败达到阈值后熔断冷却,避免每张图都卡在超时上; // - 熔断期与禁用态下不发起任何 HTTP 请求。 // // 该客户端只服务于「疑似同款」提示,**不参与任何自动去重**。 package vision import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "sync" "time" ) // MaxBatch 单次请求的最大图片数,须与服务端限制保持一致。 const MaxBatch = 32 // cooldown 熔断后的冷却时长。 const cooldown = 5 * time.Minute var ( // ErrUnavailable 表示服务不可用:未启用、熔断中、网络错误、超时、响应异常或维度不符。 ErrUnavailable = errors.New("vision service unavailable") // ErrBatchTooLarge 表示单批图片数超过 MaxBatch。 ErrBatchTooLarge = errors.New("vision batch too large") ) // embedResponse 是服务端 POST /embed 的响应结构。 type embedResponse struct { Dim int `json:"dim"` Vectors [][]float32 `json:"vectors"` Indexes []int `json:"indexes"` Skipped []int `json:"skipped"` Device string `json:"device"` } // embedRequest 是 POST /embed 的请求结构。 type embedRequest struct { Images []string `json:"images"` } // Client 是视觉服务客户端。并发安全。 type Client struct { endpoint string dim int failLimit int http *http.Client mu sync.Mutex fails int cooldownEnd time.Time disabled bool } // New 构造客户端。timeout 与 failLimit 非正时取默认值(5s / 3 次)。 func New(endpoint string, dim int, timeout time.Duration, failLimit int) *Client { if timeout <= 0 { timeout = 5 * time.Second } if failLimit <= 0 { failLimit = 3 } return &Client{ endpoint: endpoint, dim: dim, failLimit: failLimit, http: &http.Client{Timeout: timeout}, } } // Disable 关闭客户端:此后 Embed 直接返回 ErrUnavailable 且不发起请求。 // 用于配置未启用视觉服务时走零开销路径。 func (c *Client) Disable() { c.mu.Lock() defer c.mu.Unlock() c.disabled = true } // Enabled 报告客户端当前是否可发起请求(未禁用且不在熔断冷却期)。 func (c *Client) Enabled() bool { return c.Open() } // Open 报告是否可以发起请求(未禁用且不在熔断冷却期)。 func (c *Client) Open() bool { c.mu.Lock() defer c.mu.Unlock() return c.canRequestLocked() } func (c *Client) canRequestLocked() bool { if c.disabled { return false } return time.Now().After(c.cooldownEnd) } // fail 记录一次失败;达到阈值则进入熔断冷却并清零计数。 func (c *Client) fail() { c.mu.Lock() defer c.mu.Unlock() c.fails++ if c.fails >= c.failLimit { c.cooldownEnd = time.Now().Add(cooldown) c.fails = 0 } } // ok 记录一次成功,清零连续失败计数。 func (c *Client) ok() { c.mu.Lock() defer c.mu.Unlock() c.fails = 0 } // Embed 把图片字节批量编码为归一化向量,返回的 skipped 是无法解码的图片下标。 // // 任何故障(未启用 / 熔断 / 网络 / 超时 / 响应异常 / 维度不符)都返回 ErrUnavailable, // 调用方应据此跳过写向量,而不是让入库失败。 // 空输入直接返回 nil, nil, nil;超过 MaxBatch 返回 ErrBatchTooLarge。 func (c *Client) Embed(ctx context.Context, imgs [][]byte) ([][]float32, []int, error) { if len(imgs) == 0 { return nil, nil, nil } if len(imgs) > MaxBatch { return nil, nil, ErrBatchTooLarge } if !c.Open() { return nil, nil, ErrUnavailable } payload := embedRequest{Images: make([]string, len(imgs))} for i, b := range imgs { payload.Images[i] = base64.StdEncoding.EncodeToString(b) } body, err := json.Marshal(payload) if err != nil { return nil, nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/embed", bytes.NewReader(body)) if err != nil { return nil, nil, err } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { c.fail() return nil, nil, ErrUnavailable } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { c.fail() return nil, nil, ErrUnavailable } var out embedResponse if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { c.fail() return nil, nil, ErrUnavailable } if out.Dim != c.dim { // 维度不符属于配置与模型不一致:必须报错并拒写,防止污染向量列。 c.fail() return nil, nil, fmt.Errorf("%w: dim=%d want=%d", ErrUnavailable, out.Dim, c.dim) } c.ok() return out.Vectors, out.Skipped, nil }