06 - Trocando o dummy embeddings por algo que consigamos plugar facilmente

Continuação do artigo:
05 - Entender o que até agora foi feito

Vamos trocar o “dummy embeddings” por algo que você consiga plugar facilmente:

E já deixo tudo no estilo dos seus projetos: hexagonal, env vars, providers configuráveis.

Vou focar só nos pontos que mudam – o resto da estrutura (domain/app/onnx/observability/http) você já tem.


1. Porta de embeddings (domínio) – já está OK

Só relembrando, continuamos com isso em:

internal/domain/category/ports.go:

package category

import "context"

// Porta para o modelo de categorização
type CategoryPredictorPort interface {
	PredictCategory(ctx context.Context, input PredictionInput) (PredictionResult, error)
}

// Porta para geração de embeddings a partir de descrição
type EmbeddingsPort interface {
	EmbedProductDescription(ctx context.Context, description string) ([]float32, error)
}

Nada muda aqui.


2. Service continua igual – só usa a porta

internal/app/service.go já está no padrão certo (usa EmbeddingsPort), então mantemos como está:

package app

import (
	"context"
	"errors"

	"github.com/farsoft-apps/product-category-api/internal/domain/category"
)

var (
	ErrInvalidFeaturesLen = errors.New("quantidade de features inválida; esperado 16")
	ErrEmptyDescription   = errors.New("descrição do produto não pode ser vazia")
)

const ExpectedFeaturesLen = 16

type CategoryService struct {
	predictor category.CategoryPredictorPort
	embedder  category.EmbeddingsPort
}

func NewCategoryService(p category.CategoryPredictorPort, e category.EmbeddingsPort) *CategoryService {
	return &CategoryService{
		predictor: p,
		embedder:  e,
	}
}

func (s *CategoryService) PredictProductCategory(ctx context.Context, input category.PredictionInput) (category.PredictionResult, error) {
	if len(input.Features) != ExpectedFeaturesLen {
		return category.PedictionResult{}, ErrInvalidFeaturesLen
	}
	return s.predictor.PredictCategory(ctx, input)
}

func (s *CategoryService) PredictProductCategoryFromDescription(ctx context.Context, description string) (category.PredictionResult, error) {
	if description == "" {
		return category.PredictionResult{}, ErrEmptyDescription
	}

	features, err := s.embedder.EmbedProductDescription(ctx, description)
	if err != nil {
		return category.PredictionResult{}, err
	}
	if len(features) != ExpectedFeaturesLen {
		return category.PredictionResult{}, ErrInvalidFeaturesLen
	}

	input := category.PredictionInput{Features: features}
	return s.predictor.PredictCategory(ctx, input)
}

Ajuste o module path (github.com/farsoft-apps/...) para o que você realmente usa no GitHub.


3. Novo adapter de embeddings – HTTP + OpenAI-friendly

Agora vem a parte interessante: trocar o dummy por um client HTTP configurável.

3.1. Config + tipos de provider

Crie o arquivo:

internal/infra/embeddings/client.go:

package embeddings

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"strings"
	"time"

	"github.com/farsoft-apps/product-category-api/internal/domain/category"
)

type Provider string

const (
	ProviderOpenAI Provider = "openai"
	ProviderHTTP   Provider = "http" // sua própria API
)

var (
	ErrUnsupportedProvider = errors.New("provider de embeddings não suportado")
)

// Configuração genérica de embeddings
type Config struct {
	Provider Provider

	// Comum para qualquer HTTP
	BaseURL string
	APIKey  string

	// Para OpenAI: nome do modelo, ex: "text-embedding-3-small"
	Model string

	// Para provider HTTP próprio:
	// - você decide o contrato JSON; aqui vou assumir algo simples
	//   POST { "text": "..."} -> { "embedding": [ ... ] }
	CustomPath string // ex: "/v1/embeddings"
	Timeout    time.Duration
}

// HTTPEmbeddingsClient implementa EmbeddingsPort
type HTTPEmbeddingsClient struct {
	httpClient *http.Client
	cfg        Config
}

// Construtor
func NewHTTPEmbeddingsClient(cfg Config) (*HTTPEmbeddingsClient, error) {
	if cfg.Provider == "" {
		return nil, fmt.Errorf("Provider é obrigatório")
	}
	if cfg.BaseURL == "" {
		return nil, fmt.Errorf("BaseURL é obrigatório")
	}
	if cfg.Timeout == 0 {
		cfg.Timeout = 10 * time.Second
	}

	client := &HTTPEmbeddingsClient{
		httpClient: &http.Client{Timeout: cfg.Timeout},
		cfg:        cfg,
	}
	return client, nil
}

// Implementa EmbeddingsPort
func (c *HTTPEmbeddingsClient) EmbedProductDescription(ctx context.Context, description string) ([]float32, error) {
	switch c.cfg.Provider {
	case ProviderOpenAI:
		return c.callOpenAIEmbeddings(ctx, description)
	case ProviderHTTP:
		return c.callCustomHTTPEmbeddings(ctx, description)
	default:
		return nil, ErrUnsupportedProvider
	}
}

3.2. Caminho OpenAI Embeddings

No mesmo arquivo client.go, abaixo:

// ---------- OPENAI EMBEDDINGS ----------

type openAIEmbeddingsRequest struct {
	Model string   `json:"model"`
	Input []string `json:"input"`
}

type openAIEmbeddingsResponse struct {
	Data []struct {
		Embedding []float64 `json:"embedding"`
	} `json:"data"`
}

// BaseURL típico: https://api.openai.com
// Rota: POST /v1/embeddings
func (c *HTTPEmbeddingsClient) callOpenAIEmbeddings(ctx context.Context, description string) ([]float32, error) {
	if c.cfg.Model == "" {
		return nil, fmt.Errorf("Model é obrigatório para ProviderOpenAI")
	}
	url := strings.TrimRight(c.cfg.BaseURL, "/") + "/v1/embeddings"

	reqBody := openAIEmbeddingsRequest{
		Model: c.cfg.Model,
		Input: []string{description},
	}

	bodyBytes, err := json.Marshal(reqBody)
	if err != nil {
		return nil, fmt.Errorf("erro ao serializar request de embeddings: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes)))
	if err != nil {
		return nil, fmt.Errorf("erro ao criar request HTTP: %w", err)
	}

	req.Header.Set("Content-Type", "application/json")
	if c.cfg.APIKey != "" {
		req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("erro ao chamar OpenAI embeddings: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 300 {
		return nil, fmt.Errorf("OpenAI embeddings retornou status %d", resp.StatusCode)
	}

	var apiResp openAIEmbeddingsResponse
	if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
		return nil, fmt.Errorf("erro ao decodificar resposta OpenAI: %w", err)
	}

	if len(apiResp.Data) == 0 {
		return nil, fmt.Errorf("resposta OpenAI sem dados de embedding")
	}

	return float64SliceTo32(apiResp.Data[0].Embedding), nil
}

3.3. Caminho para sua própria API HTTP

Aqui você vai encaixar a sua API escrita em Go (ou outra stack), desde que ela exponha um endpoint HTTP.

Vamos assumir um contrato simples:

Também no client.go:

// ---------- PROVIDER HTTP PRÓPRIO ----------

type customEmbeddingsRequest struct {
	Text string `json:"text"`
}

type customEmbeddingsResponse struct {
	Embedding []float64 `json:"embedding"`
}

// BaseURL típico: http://meu-embeddings:8080
// Path: CustomPath, ex: /v1/embeddings
func (c *HTTPEmbeddingsClient) callCustomHTTPEmbeddings(ctx context.Context, description string) ([]float32, error) {
	path := c.cfg.CustomPath
	if path == "" {
		path = "/v1/embeddings"
	}

	url := strings.TrimRight(c.cfg.BaseURL, "/") + path

	reqBody := customEmbeddingsRequest{
		Text: description,
	}

	bodyBytes, err := json.Marshal(reqBody)
	if err != nil {
		return nil, fmt.Errorf("erro ao serializar request custom de embeddings: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes)))
	if err != nil {
		return nil, fmt.Errorf("erro ao criar request HTTP custom: %w", err)
	}

	req.Header.Set("Content-Type", "application/json")
	if c.cfg.APIKey != "" {
		req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("erro ao chamar provider HTTP de embeddings: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 300 {
		return nil, fmt.Errorf("provider HTTP embeddings retornou status %d", resp.StatusCode)
	}

	var apiResp customEmbeddingsResponse
	if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
		return nil, fmt.Errorf("erro ao decodificar resposta custom embeddings: %w", err)
	}

	if len(apiResp.Embedding) == 0 {
		return nil, fmt.Errorf("resposta custom embeddings sem vetor")
	}

	return float64SliceTo32(apiResp.Embedding), nil
}

3.4. Helper para converter []float64[]float32

Ainda em client.go:

func float64SliceTo32(in []float64) []float32 {
	out := make([]float32, len(in))
	for i, v := range in {
		out[i] = float32(v)
	}
	return out
}

4. main.go – montar embedder via env (no estilo que você usa)

Agora no cmd/api/main.go a gente troca:

Exemplo:

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"time"

	ort "github.com/yalue/onnxruntime_go"

	"github.com/farsoft-apps/product-category-api/internal/app"
	apihttp "github.com/farsoft-apps/product-category-api/internal/http"
	"github.com/farsoft-apps/product-category-api/internal/infra/embeddings"
	"github.com/farsoft-apps/product-category-api/internal/infra/observability"
	"github.com/farsoft-apps/product-category-api/internal/infra/onnx"
)

func main() {
	ctx := context.Background()

	if libPath := os.Getenv("ONNXRUNTIME_LIB"); libPath != "" {
		ort.SetSharedLibraryPath(libPath)
	}
	if err := ort.InitializeEnvironment(); err != nil {
		log.Fatalf("erro inicializando onnxruntime: %v", err)
	}
	defer func() {
		if err := ort.DestroyEnvironment(); err != nil {
			log.Printf("erro destruindo ambiente onnxruntime: %v", err)
		}
	}()

	observability.InitMetrics()

	shutdownTracer, err := observability.InitTracer(ctx, "product-category-api")
	if err != nil {
		log.Printf("erro inicializando tracer: %v", err)
	} else {
		defer func() {
			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
			defer cancel()
			if err := shutdownTracer(ctx); err != nil {
				log.Printf("erro encerrando tracer: %v", err)
			}
		}()
	}

	modelPath := os.Getenv("MODEL_PATH")
	if modelPath == "" {
		modelPath = "model/model.onnx"
	}

	classNames := []string{"AUTOPECAS", "INFORMATICA", "MATERIAL_CONSTRUCAO"}
	expectedFeatures := 16

	predictor, err := onnx.NewOnnxCategoryPredictor(modelPath, classNames, expectedFeatures)
	if err != nil {
		log.Fatalf("erro criando predictor ONNX: %v", err)
	}

	// ---------- Embeddings Provider via ENV ----------

	// EMBEDDINGS_PROVIDER = "openai" ou "http"
	providerStr := os.Getenv("EMBEDDINGS_PROVIDER")
	if providerStr == "" {
		providerStr = string(embeddings.ProviderHTTP) // default: sua própria API
	}
	provider := embeddings.Provider(providerStr)

	baseURL := os.Getenv("EMBEDDINGS_BASE_URL")
	apiKey := os.Getenv("EMBEDDINGS_API_KEY")
	modelName := os.Getenv("EMBEDDINGS_MODEL") // para OpenAI, ex: "text-embedding-3-small"
	customPath := os.Getenv("EMBEDDINGS_CUSTOM_PATH")

	embCfg := embeddings.Config{
		Provider:   provider,
		BaseURL:    baseURL,
		APIKey:     apiKey,
		Model:      modelName,
		CustomPath: customPath,
		Timeout:    10 * time.Second,
	}

	embedder, err := embeddings.NewHTTPEmbeddingsClient(embCfg)
	if err != nil {
		log.Fatalf("erro criando client de embeddings: %v", err)
	}

	svc := app.NewCategoryService(predictor, embedder)
	handlers := apihttp.NewCategoryHandlers(svc)
	router := apihttp.NewRouter(handlers)

	addr := ":8000"
	log.Printf("API de categorização de produtos ouvindo em %s", addr)
	if err := http.ListenAndServe(addr, router); err != nil {
		log.Fatalf("erro servidor HTTP: %v", err)
	}
}

5. Environment vars no docker-compose

5.1. Caso OpenAI

No docker-compose.yml, para usar OpenAI:

  api-go:
    build:
      context: .
      dockerfile: Dockerfile.api-go
    volumes:
      - ./model:/app/model:ro
    environment:
      - ONNXRUNTIME_LIB=/usr/local/lib/libonnxruntime.so
      - MODEL_PATH=/app/model/model.onnx
      - OTEL_EXPORTER_OTLP_ENDPOINT=tempo:4317

      - EMBEDDINGS_PROVIDER=openai
      - EMBEDDINGS_BASE_URL=https://api.openai.com
      - EMBEDDINGS_API_KEY=${OPENAI_API_KEY}
      - EMBEDDINGS_MODEL=text-embedding-3-small
    ports:
      - "8000:8000"
    depends_on:
      - tempo

Aí você só precisa passar OPENAI_API_KEY no .env ou no ambiente do host.


5.2. Caso sua própria API de embeddings em Go

Suponha que você tenha outro serviço:

  my-embeddings-api:
    image: farsoft/embeddings-api:latest
    ports:
      - "8081:8080"

E ele exponha POST /v1/embeddings com o contrato { "text": "..."} -> { "embedding": [...] }.

Aí:

  api-go:
    ...
    environment:
      - ONNXRUNTIME_LIB=/usr/local/lib/libonnxruntime.so
      - MODEL_PATH=/app/model/model.onnx
      - OTEL_EXPORTER_OTLP_ENDPOINT=tempo:4317

      - EMBEDDINGS_PROVIDER=http
      - EMBEDDINGS_BASE_URL=http://my-embeddings-api:8080
      - EMBEDDINGS_CUSTOM_PATH=/v1/embeddings
      - EMBEDDINGS_API_KEY= # se precisar auth, você define

6. Testando com descrição (sem mais dummy)

curl -X POST http://localhost:8000/predict-description \
  -H "Content-Type: application/json" \
  -d '{"descricao": "Kit de embreagem para Gol 1.6 8v"}'

Fluxo:

  1. Handler recebe a descrição.

  2. Service chama embedder.EmbedProductDescription.

  3. Esse embedder:

    • Vai na OpenAI (/v1/embeddings) ou na sua API Go (/v1/embeddings própria).

    • Converte []float64 para []float32.

  4. Service valida o tamanho (16 – baseado no modelo treinado).

  5. ONNX predictor recebe as features, executa o modelo e devolve a categoria.

  6. Handler retorna JSON com category_code, category_name e probabilities.


Ver depois:
07 - Formatando a minha maneira