// Package embedding provides an optional OpenAI embeddings client. // It is intentionally not wired into patrol/graph flows; relevance uses // offline lexical intent scoring in knowledge/semantic_fit.go instead. package embedding import ( "bytes" "context" "encoding/json" "fmt" "io" "math" "net/http" "strings" ) const DefaultModel = "text-embedding-3-small" type Client struct { apiKey string model string http *http.Client } func NewClient(apiKey string) *Client { return &Client{ apiKey: strings.TrimSpace(apiKey), model: DefaultModel, http: &http.Client{}, } } func (c *Client) Enabled() bool { return c != nil && c.apiKey != "" } type EmbeddingRequest struct { Input []string `json:"input"` Model string `json:"model"` } type EmbeddingResponse struct { Data []struct { Embedding []float32 `json:"embedding"` } `json:"data"` Error *struct { Message string `json:"message"` } `json:"error,omitempty"` Usage *struct { TotalTokens int `json:"total_tokens"` } `json:"usage,omitempty"` } func (c *Client) Embed(ctx context.Context, input string) ([]float32, error) { if !c.Enabled() { return nil, fmt.Errorf("embedding client not configured") } return c.EmbedBatch(ctx, []string{input}) } func (c *Client) EmbedBatch(ctx context.Context, inputs []string) ([]float32, error) { if !c.Enabled() { return nil, fmt.Errorf("embedding client not configured") } if len(inputs) == 0 { return nil, fmt.Errorf("no input text") } req := EmbeddingRequest{ Input: inputs, Model: c.model, } body, err := json.Marshal(req) if err != nil { return nil, err } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.openai.com/v1/embeddings", bytes.NewReader(body)) if err != nil { return nil, err } httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) httpReq.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(httpReq) if err != nil { return nil, err } defer resp.Body.Close() respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { var errResp struct { Error struct { Message string `json:"message"` } `json:"error"` } if json.Unmarshal(respBody, &errResp) == nil && errResp.Error.Message != "" { return nil, fmt.Errorf("openai embedding: %s", errResp.Error.Message) } return nil, fmt.Errorf("openai embedding: http %d", resp.StatusCode) } var result EmbeddingResponse if err := json.Unmarshal(respBody, &result); err != nil { return nil, err } if result.Error != nil && result.Error.Message != "" { return nil, fmt.Errorf("openai embedding: %s", result.Error.Message) } if len(result.Data) == 0 { return nil, fmt.Errorf("openai embedding: no data returned") } return result.Data[0].Embedding, nil } func CosineSimilarity(a, b []float32) float32 { if len(a) == 0 || len(b) == 0 || len(a) != len(b) { return 0 } var dot, normA, normB float64 for i := 0; i < len(a); i++ { dot += float64(a[i] * b[i]) normA += float64(a[i] * a[i]) normB += float64(b[i] * b[i]) } if normA == 0 || normB == 0 { return 0 } return float32(dot / (math.Sqrt(normA) * math.Sqrt(normB))) }