package processing import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "strings" "time" ) // PineconeCategorizer implements VectorCategorizer via Pinecone query API. // When Embedder is set (admin AI role "vectorization" / env fallback), queries // send an explicit vector; otherwise Text is used (Pinecone integrated inference). // ASSUMPTION: when not configured, Enabled() is false and callers skip vector categorize. type PineconeCategorizer struct { APIKey string Host string Namespace string HTTPClient *http.Client Embedder Embedder } func NewPineconeCategorizer(apiKey, host, namespace string) *PineconeCategorizer { return &PineconeCategorizer{ APIKey: strings.TrimSpace(apiKey), Host: strings.TrimRight(strings.TrimSpace(host), "/"), Namespace: namespace, HTTPClient: &http.Client{Timeout: 20 * time.Second}, } } func (p *PineconeCategorizer) Enabled() bool { return p != nil && p.APIKey != "" && p.Host != "" } type pineconeQueryRequest struct { Namespace string `json:"namespace,omitempty"` TopK int `json:"topK"` IncludeMetadata bool `json:"includeMetadata"` Vector []float32 `json:"vector,omitempty"` Text string `json:"text,omitempty"` } type pineconeQueryResponse struct { Matches []struct { ID string `json:"id"` Score float64 `json:"score"` Metadata map[string]any `json:"metadata"` } `json:"matches"` } func (p *PineconeCategorizer) SuggestCategory(ctx context.Context, companyID, productText string, candidates []string) (string, error) { if !p.Enabled() { return "", errors.New("pinecone not configured") } productText = SanitizeText(productText) if productText == "" { return "", errors.New("empty product text") } _ = companyID _ = candidates reqBody := pineconeQueryRequest{ Namespace: p.Namespace, TopK: 1, IncludeMetadata: true, } if p.Embedder != nil { vecs, err := p.Embedder.Embed(ctx, []string{productText}) if err != nil { return "", err } if len(vecs) == 0 || len(vecs[0]) == 0 { return "", errors.New("empty embedding") } reqBody.Vector = vecs[0] } else { reqBody.Text = productText } body, err := json.Marshal(reqBody) if err != nil { return "", err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.Host+"/query", bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Api-Key", p.APIKey) res, err := p.HTTPClient.Do(req) if err != nil { return "", err } defer res.Body.Close() raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return "", err } if res.StatusCode >= 400 { return "", errors.New(TruncateError(errors.New("pinecone query failed"))) } var parsed pineconeQueryResponse if err := json.Unmarshal(raw, &parsed); err != nil { return "", err } if len(parsed.Matches) == 0 { return "", errors.New("no pinecone matches") } m := parsed.Matches[0].Metadata if m != nil { if name, ok := m["category"].(string); ok && strings.TrimSpace(name) != "" { return SanitizeOutput(name), nil } if name, ok := m["name"].(string); ok && strings.TrimSpace(name) != "" { return SanitizeOutput(name), nil } } return SanitizeOutput(parsed.Matches[0].ID), nil } // NoopVectorCategorizer is the default when Pinecone is unset. type NoopVectorCategorizer struct{} func (NoopVectorCategorizer) Enabled() bool { return false } func (NoopVectorCategorizer) SuggestCategory(context.Context, string, string, []string) (string, error) { return "", errors.New("vector categorizer disabled") }