05 - Entender o que até agora foi feito

Continuação do artigo:
04 - Convertendo para arquitetura hexagonal

Vamos por partes e entender tudo que está acontecendo.


1. O que esse código faz, em linguagem “de gente”

1.1. No Python (treino)

O script train_product_category.py faz:

  1. Define um modelo simples em PyTorch (ProductCategoryNet):

    • Entrada: vetor com 16 números (NUM_FEATURES = 16) — imaginando que seja um embedding do produto.

    • Camada oculta de 32 neurônios com ReLU.

    • Saída: 3 neurônios (3 categorias).

  2. Gera dados sintéticos:

    • Cria 3 “nuvens” de pontos no espaço de 16 dimensões:

      • AUTOPECAS → em torno de 0.0

      • INFORMATICA → em torno de 3.0

      • MATERIAL_CONSTRUCAO → em torno de -3.0

    • Embaralha tudo e cria X (features) e y (labels 0,1,2).

  3. Treina o modelo:

    • Usa CrossEntropyLoss + Adam.

    • Roda algumas épocas e imprime loss e accuracy.

  4. Exporta para ONNX:

    • Cria um input de teste (“dummy_input”) com shape [1, 16].

    • Exporta o modelo para model/model.onnx com:

      • input_names=["input"]

      • output_names=["output"]

      • dynamic_axes para dimensionar por batch.

    • Resultado: arquivo model.onnx pronto para ser usado em qualquer runtime compatível (no nosso caso, ONNX Runtime no Go).


1.2. No Go (arquitetura hexagonal)

A API em Go foi pensada em camadas:

Domínio (internal/domain/category)

Ou seja: nada aqui sabe de ONNX, HTTP, JSON, nada. Só conceito de categorização.


Aplicação (internal/app/service.go)

Ainda não sabe de HTTP, ONNX, logs, etc. Mas já conhece “regras de aplicação” (ex: exatamente 16 features).


Infraestrutura – ONNX (internal/infra/onnx/predictor.go)

Essa camada é totalmente plugável: amanhã você pode trocar ONNX por outra coisa mantendo a interface.


Observabilidade (internal/infra/observability)


HTTP (internal/http)


cmd/api/main.go


2. Adaptar para receber descrição em vez de vetor de features

Agora vamos dar o passo que você pediu:

“Adapte para receber descrição”

Arquiteturalmente, isso significa:

  1. Criar uma porta de embeddings no domínio (ou aplicação).

  2. Ter um adapter de infraestrutura que implementa essa porta:

    • No exemplo: um “dummy embedding” (matemática boba só pra exemplo).

    • Em produção: você pluga OpenAI, modelo local, serviço próprio etc.

  3. Expor um endpoint HTTP que recebe:

    • { "descricao": "Pastilha de freio dianteira Corolla 2018" }
  4. O fluxo vira:

    • descrição → embeddings → modelo ONNX → categoria.

Vou te mostrar só as partes novas/alteradas (pra não repetir tudo).


2.1. Nova porta de embeddings

internal/domain/category/ports.go (adicionar outra interface)

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

2.2. Service agora usa predictor e embedder

internal/app/service.go (refatorado)

package app

import (
	"context"
	"errors"

	"github.com/seuuser/ml-onnx-example/api-go/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,
	}
}

// Caso de uso original: já recebo as features
func (s *CategoryService) PredictProductCategory(ctx context.Context, input category.PredictionInput) (category.PredictionResult, error) {
	if len(input.Features) != ExpectedFeaturesLen {
		return category.PredictionResult{}, ErrInvalidFeaturesLen
	}
	return s.predictor.PredictCategory(ctx, input)
}

// Novo caso de uso: recebo uma descrição em texto
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)
}

2.3. Adapter de embeddings (dummy, plugável)

A ideia aqui é só dar estrutura. Em produção, você troca o corpo da função para usar:

internal/infra/embeddings/dummy.go

package embeddings

import (
	"context"
	"strings"
	"unicode/utf8"

	"github.com/seuuser/ml-onnx-example/api-go/internal/domain/category"
)

const EmbeddingSize = 16

// DummyEmbeddings implementa EmbeddingsPort de forma simples e determinística.
// É só para exemplo / POC. Você deve trocar pela sua engine real de embeddings.
type DummyEmbeddings struct{}

func NewDummyEmbeddings() *DummyEmbeddings {
	return &DummyEmbeddings{}
}

func (d *DummyEmbeddings) EmbedProductDescription(ctx context.Context, description string) ([]float32, error) {
	// Normaliza: tudo minúsculo
	desc := strings.ToLower(description)

	vec := make([]float32, EmbeddingSize)

	// Regras bobas, só para ter algo numérico:
	// - soma de códigos das letras
	// - quantidade de palavras
	// - comprimentos médios etc.
	words := strings.Fields(desc)
	wordCount := len(words)
	totalRunes := utf8.RuneCountInString(desc)

	if wordCount > 0 {
		vec[0] = float32(wordCount)
	}
	vec[1] = float32(totalRunes)

	// Distribui caracteres nas posições
	for i, r := range desc {
		idx := i % EmbeddingSize
		vec[idx] += float32(r % 64) // joga mod 64 só pra não explodir
	}

	// Normalização bem tosca (apenas exemplo)
	for i := range vec {
		vec[i] = vec[i] / 100.0
	}

	return vec, nil
}

Depois, quando você tiver embeddings reais, é só criar outro adapter, ex.:


2.4. Handlers HTTP agora aceitam descrição

Vamos manter dois endpoints:

internal/http/handlers.go (versão adaptada)

package http

import (
	"encoding/json"
	"net/http"

	"github.com/seuuser/ml-onnx-example/api-go/internal/app"
	"github.com/seuuser/ml-onnx-example/api-go/internal/domain/category"
)

type CategoryHandlers struct {
	svc *app.CategoryService
}

func NewCategoryHandlers(svc *app.CategoryService) *CategoryHandlers {
	return &CategoryHandlers{svc: svc}
}

type predictRequest struct {
	Features []float32 `json:"features"`
}

type predictDescriptionRequest struct {
	Descricao string `json:"descricao"`
}

type predictResponse struct {
	CategoryCode string    `json:"category_code"`
	CategoryName string    `json:"category_name"`
	Probabilities []float32 `json:"probabilities"`
}

func (h *CategoryHandlers) Healthz(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// Continua existindo: recebe vetor numérico
func (h *CategoryHandlers) PredictCategory(w http.ResponseWriter, r *http.Request) {
	var req predictRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "JSON inválido")
		return
	}

	input := category.PredictionInput{
		Features: req.Features,
	}

	result, err := h.svc.PredictProductCategory(r.Context(), input)
	if err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}

	resp := predictResponse{
		CategoryCode: result.Category.Code,
		CategoryName: result.Category.Name,
		Probabilities: result.Probabilities,
	}
	writeJSON(w, http.StatusOK, resp)
}

// Novo endpoint: recebe descrição em texto
func (h *CategoryHandlers) PredictCategoryFromDescription(w http.ResponseWriter, r *http.Request) {
	var req predictDescriptionRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "JSON inválido")
		return
	}

	result, err := h.svc.PredictProductCategoryFromDescription(r.Context(), req.Descricao)
	if err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}

	resp := predictResponse{
		CategoryCode: result.Category.Code,
		CategoryName: result.Category.Name,
		Probabilities: result.Probabilities,
	}
	writeJSON(w, http.StatusOK, resp)
}

func writeJSON(w http.ResponseWriter, status int, payload any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(payload)
}

func writeError(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}

internal/http/router.go (registrando nova rota)

package http

import (
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"

	"github.com/seuuser/ml-onnx-example/api-go/internal/infra/observability"

	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func NewRouter(h *CategoryHandlers) http.Handler {
	r := chi.NewRouter()

	r.Use(middleware.RequestID)
	r.Use(middleware.RealIP)
	r.Use(middleware.Logger)
	r.Use(middleware.Recoverer)

	r.Use(observability.MetricsMiddleware)

	r.Get("/healthz", h.Healthz)
	r.Post("/predict", h.PredictCategory)
	r.Post("/predict-description", h.PredictCategoryFromDescription)

	r.Handle("/metrics", observability.MetricsHandler())

	return otelhttp.NewHandler(r, "product-category-api")
}

2.5. main.go agora instancia o embedder também

cmd/api/main.go (trecho de montagem)

package main

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

	ort "github.com/yalue/onnxruntime_go"

	"github.com/seuuser/ml-onnx-example/api-go/internal/app"
	apihttp "github.com/seuuser/ml-onnx-example/api-go/internal/http"
	"github.com/seuuser/ml-onnx-example/api-go/internal/infra/embeddings"
	"github.com/seuuser/ml-onnx-example/api-go/internal/infra/observability"
	"github.com/seuuser/ml-onnx-example/api-go/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)
	}

	embedder := embeddings.NewDummyEmbeddings()

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

Testando o endpoint com descrição

curl -X POST http://localhost:8000/predict-description \
  -H "Content-Type: application/json" \
  -d '{"descricao": "Pastilha de freio dianteira Corolla 2018"}'

3. docker-compose com Prometheus + Grafana + Loki + Tempo

Agora o cenário completo:

3.1. Estrutura de arquivos

docker-compose.yml
prometheus/
  prometheus.yml
loki/
  config.yml
tempo/
  tempo.yml
promtail/
  config.yml
grafana/
  provisioning/
    datasources/
      datasources.yml
    dashboards/
      dashboards.yml
  dashboards/
    product-api.json

3.2. docker-compose.yml

version: "3.9"

services:
  trainer:
    build:
      context: .
      dockerfile: Dockerfile.ml
    volumes:
      - ./model:/app/model
    command: ["python", "ml/train_product_category.py"]

  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
    ports:
      - "8000:8000"
    depends_on:
      - tempo

  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
    command:
      - "--config.file=/etc/prometheus/prometheus.yml"
    ports:
      - "9090:9090"
    depends_on:
      - api-go

  loki:
    image: grafana/loki:3.0.0
    command: -config.file=/etc/loki/config.yml
    volumes:
      - ./loki/config.yml:/etc/loki/config.yml
    ports:
      - "3100:3100"

  promtail:
    image: grafana/promtail:3.0.0
    command: -config.file=/etc/promtail/config.yml
    volumes:
      - ./promtail/config.yml:/etc/promtail/config.yml
      - /var/log:/var/log
      - /var/lib/docker/containers:/var/lib/docker/containers
    depends_on:
      - loki

  tempo:
    image: grafana/tempo:2.6.0
    command: [ "-config.file=/etc/tempo/tempo.yml" ]
    volumes:
      - ./tempo/tempo.yml:/etc/tempo/tempo.yml
    ports:
      - "3200:3200"  # http
      - "4317:4317"  # otlp-grpc

  grafana:
    image: grafana/grafana:11.0.0
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=admin
    ports:
      - "3000:3000"
    depends_on:
      - prometheus
      - loki
      - tempo
    volumes:
      - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources
      - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards
      - ./grafana/dashboards:/var/lib/grafana/dashboards

Obs.: o promtail precisa ter acesso aos logs do Docker do host; se estiver usando Docker Desktop/WSL, pode precisar ajustar os paths.


3.3. Prometheus – prometheus/prometheus.yml

global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'api-go'
    static_configs:
      - targets: ['api-go:8000']

3.4. Loki – loki/config.yml (bem simples)

server:
  http_listen_port: 3100

distributor:
  ring:
    kvstore:
      store: inmemory

ingester:
  lifecycler:
    ring:
      kvstore:
        store: inmemory
      replication_factor: 1
  chunk_idle_period: 1h
  max_chunk_age: 1h
  chunk_target_size: 1048576

querier:
  engine:
    timeout: 60s

query_range:
  parallelise_shardable_queries: true
  split_queries_by_interval: 15m

schema_config:
  configs:
    - from: 2024-01-01
      store: boltdb-shipper
      object_store: filesystem
      schema: v12
      index:
        prefix: index_
        period: 24h

storage_config:
  boltdb_shipper:
    active_index_directory: /loki/index
    cache_location: /loki/index_cache
    shared_store: filesystem
  filesystem:
    directory: /loki/chunks

ruler:
  storage:
    type: local
    local:
      directory: /loki/rules
  rule_path: /loki/rules-temp
  ring:
    kvstore:
      store: inmemory

compactor:
  working_directory: /loki/compactor
  shared_store: filesystem

3.5. Promtail – promtail/config.yml (coletando logs de containers)

server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: docker-logs
    static_configs:
      - targets:
          - localhost
        labels:
          job: docker
          __path__: /var/lib/docker/containers/*/*.log

3.6. Tempo – tempo/tempo.yml

server:
  http_listen_port: 3200
  grpc_listen_port: 4317

distributor:
  receivers:
    otlp:
      protocols:
        grpc:

ingester:
  traces:
    wal:
      enabled: false

compactor:
  compaction:
    block_retention: 24h

storage:
  trace:
    backend: local
    local:
      path: /tmp/tempo/traces

3.7. Grafana – datasources

grafana/provisioning/datasources/datasources.yml

apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true

  - name: Loki
    type: loki
    access: proxy
    url: http://loki:3100

  - name: Tempo
    type: tempo
    access: proxy
    url: http://tempo:3200

3.8. Grafana – provisionamento de dashboards

grafana/provisioning/dashboards/dashboards.yml

apiVersion: 1

providers:
  - name: 'product-api'
    orgId: 1
    folder: 'APIs'
    type: file
    disableDeletion: false
    updateIntervalSeconds: 10
    options:
      path: /var/lib/grafana/dashboards

3.9. Dashboard simples – grafana/dashboards/product-api.json

Um dashboard bem resumido, com:

{
  "id": null,
  "uid": "product-api",
  "title": "Product Category API",
  "schemaVersion": 38,
  "version": 1,
  "panels": [
    {
      "type": "stat",
      "title": "Requests por segundo",
      "targets": [
        {
          "expr": "sum(rate(http_requests_total[1m]))",
          "refId": "A"
        }
      ],
      "gridPos": { "x": 0, "y": 0, "w": 8, "h": 6 }
    },
    {
      "type": "graph",
      "title": "Latência (p95)",
      "targets": [
        {
          "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
          "refId": "A"
        }
      ],
      "gridPos": { "x": 8, "y": 0, "w": 16, "h": 6 }
    },
    {
      "type": "stat",
      "title": "Taxa de erros (5xx)",
      "targets": [
        {
          "expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m]))",
          "refId": "A"
        }
      ],
      "gridPos": { "x": 0, "y": 6, "w": 8, "h": 6 }
    }
  ],
  "time": {
    "from": "now-15m",
    "to": "now"
  }
}

4. Como rodar o conjunto todo

  1. Treinar o modelo (gera model/model.onnx):
docker compose run --rm trainer
  1. Subir o stack inteiro (API + observabilidade):
docker compose up -d api-go prometheus loki promtail tempo grafana
  1. Testar a API:
curl -X POST http://localhost:8000/predict-description \
  -H "Content-Type: application/json" \
  -d '{"descricao": "SSD 1TB NVMe PCIe 4.0 para notebook gamer"}'
  1. Abrir o Grafana:

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