Files
descrybe/apps/api/internal/processing/pinecone.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

134 lines
3.6 KiB
Go

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")
}