Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
134 lines
3.6 KiB
Go
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")
|
|
}
|