ML Service - Setup & Development Guide
📋 Visão Geral
Serviço Python dedicado para Machine Learning no ecossistema Audissey, incluindo:
- Recomendações de conteúdo (collaborative + content-based)
- Classificação automática de gêneros
- Geração de embeddings de texto e áudio
- Análise de sentimento
🚀 Quick Start
1. Criar Virtual Environment
cd apps/ml-service
# Criar venv
python3.11 -m venv venv
# Ativar (Mac/Linux)
source venv/bin/activate
# Ativar (Windows)
venv\Scripts\activate
2. Instalar Dependências
# Produção
pip install -r requirements.txt
# Desenvolvimento
pip install -r requirements-dev.txt
lightfm 1.17 não instala com
pip install. Osetup.pyusa__builtins__.__LIGHTFM_SETUP__, que rebenta sob PEP 517 (e o própriopip downloadjá corre o
PEP 517 nas versões recentes do pip). Constrói-se a partir do sdist, como noDockerfile, com
Python 3.11 e Cython < 3. O.venvda raiz do repo é Python 3.9 sem lightfm e não serve para os
testes do recomendador (os testes com LightFM real fazemimportorskip).cd apps/ml-service uv venv --python 3.11 .venv && P=$PWD/.venv/bin/python # .venv está no .gitignore uv pip install --python $P "setuptools<75" "cython<3" "numpy==1.26.3" wheel # sem lightfm; torch/transformers não são precisos para os testes do recomendador grep -v -E '^(lightfm|transformers|sentence-transformers)==' requirements.txt > /tmp/req-nolfm.txt uv pip install --python $P -r /tmp/req-nolfm.txt -r requirements-dev.txt mkdir -p /tmp/lfm && curl -fsSL -o /tmp/lfm/lightfm-1.17.tar.gz \ https://files.pythonhosted.org/packages/source/l/lightfm/lightfm-1.17.tar.gz tar xzf /tmp/lfm/lightfm-1.17.tar.gz -C /tmp/lfm (cd /tmp/lfm/lightfm-1.17 && $P setup.py bdist_wheel) uv pip install --python $P /tmp/lfm/lightfm-1.17/dist/lightfm-1.17-*.whl $P -m pytest tests/unit -q && $P -m ruff check src tests && $P -m black --check src testsNo macOS o LightFM compila sem OpenMP (avisa que usa 1 thread); os resultados não mudam.
3. Configurar Environment
# Copiar exemplo
cp .env.example .env
# Editar .env
nano .env
4. Rodar Serviço
# Modo desenvolvimento (auto-reload)
uvicorn src.main:app --reload --port 8000
# Ou usando Python
python -m src.main
Acesse:
- API: http://localhost:8000
- Docs: http://localhost:8000/docs
- Health: http://localhost:8000/health
🧪 Testes
Rodar Todos os Testes
pytest
Com Coverage
pytest --cov=src --cov-report=html
open htmlcov/index.html
Apenas Unit Tests
pytest tests/unit/ -v
Teste Específico
pytest tests/unit/test_config.py::test_settings_defaults -v
🔍 Linting & Formatting
Ruff (Linting)
# Check
ruff check src/ tests/
# Auto-fix
ruff check --fix src/ tests/
Black (Formatting)
# Check
black --check src/ tests/
# Format
black src/ tests/
isort (Import Order)
# Check
isort --check-only src/ tests/
# Fix
isort src/ tests/
mypy (Type Checking)
mypy src/ --ignore-missing-imports
Rodar Tudo
# Script helper
cat > lint.sh << 'EOF'
#!/bin/bash
echo "🔍 Running ruff..."
ruff check src/ tests/
echo "🎨 Running black..."
black --check src/ tests/
echo "📦 Running isort..."
isort --check-only src/ tests/
echo "🔍 Running mypy..."
mypy src/ --ignore-missing-imports
EOF
chmod +x lint.sh
./lint.sh
🐳 Docker
Build
docker build -t audissey-ml:latest -f Dockerfile .
Run
docker run -p 8000:8000 \
-e MONGO_URI="mongodb://host.docker.internal:27017/audissey" \
-e REDIS_URL="redis://host.docker.internal:6379" \
audissey-ml:latest
Com docker-compose
# docker-compose.yml
services:
ml-service:
build:
context: ./apps/ml-service
dockerfile: Dockerfile
ports:
- '8000:8000'
environment:
- MONGO_URI=mongodb://mongo:27017/audissey
- REDIS_URL=redis://redis:6379
- ENVIRONMENT=development
depends_on:
- mongo
- redis
📂 Estrutura do Projeto
apps/ml-service/
├── src/
│ ├── __init__.py
│ ├── main.py # FastAPI app
│ ├── config.py # Settings (Pydantic)
│ │
│ ├── models/ # ML Models
│ │ ├── __init__.py
│ │ ├── recommender.py # Recommendation engine
│ │ └── embeddings.py # Text/Audio embeddings
│ │
│ ├── services/ # Business logic
│ │ ├── __init__.py
│ │ └── classification.py # Genre classification
│ │
│ └── utils/
│ ├── __init__.py
│ └── helpers.py
│
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Pytest fixtures
│ ├── unit/
│ │ ├── test_config.py
│ │ ├── test_main.py
│ │ ├── test_embeddings.py
│ │ ├── test_recommender.py
│ │ └── test_classification.py
│ └── integration/
│
├── models/ # Trained models (gitignored)
├── requirements.txt
├── requirements-dev.txt
├── pyproject.toml
├── Dockerfile
├── .env.example
├── .gitignore
└── README.md
🔧 Configuração
Variáveis de Ambiente
Ver .env.example para todas as opções.
Principais:
# Aplicação
ENVIRONMENT=development|production
LOG_LEVEL=DEBUG|INFO|WARNING|ERROR
# Database
MONGO_URI=mongodb://localhost:27017/audissey
REDIS_URL=redis://localhost:6379
# ML
ML_MODEL_PATH=./models
EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
RECOMMENDATION_WEIGHT_BY_SIGNAL=false # ver "Sinal de tempo de escuta no treino"
Sinal de tempo de escuta no treino
listenTimeMsByContent (snapshot ml_training_data) tem chaves "<tipo>:<id>" (radio:<id>,episode:<id>, podcast:<id>). Cada par user-item recebe uma força pelo tempo de escuta desse
item: 5 min ou mais: 1.0; 30 s a 5 min: 0.5; menos de 30 s: 0.1; sem tempo de escuta: 0.
Plays por item. O worker grava playsByContent (número de play_started do dia por item, mesmas
chaves "<tipo>:<id>") em UserFeatures.dailyMetrics[] e em cada linha do snapshot. Quando o item
tem mais de 1 play, a força é multiplicada por 1 + ln(plays) (2 plays: ×1.69; 5: ×2.61; 20: ×4.0):
um item repetido pesa mais, sem que um único utilizador que repete muito domine o treino. Os plays
só escalam tempo de escuta: um item com plays e sem escuta continua com força 0. Snapshots gravados
antes desta mudança não têm o campo (e um item ouvido sem play_started nesse dia não tem entrada),
e ficam só com o escalão de escuta. playsByContentType continua a alimentar os totais do utilizador.
O snapshot é um cross-join utilizador × catálogo: cada linha de um utilizador repete os mesmos
mapas diários, por isso os totais do utilizador (total_plays, total_listen_ms, total_skips) são
lidos uma vez por utilizador. Antes eram somados por linha (× tamanho do catálogo), o que punha
qualquer utilizador com 1 play em eng:high.
Sem a flag o modelo não aprende nada. No cross-join todos os itens são positivos de todos os
utilizadores, e o WARP do LightFM volta a sortear um negativo que também é positivo: nunca
encontra um negativo e nunca atualiza. Os embeddings ficam no estado aleatório inicial (testetest_lightfm_learns_nothing_from_the_unweighted_cross_join; confirmado nos snapshots de staging).sample_weightsozinho não muda isto: escala atualizações que não acontecem.
Flag RECOMMENDATION_WEIGHT_BY_SIGNAL (default false). Ligada, o treino usa só os pares com
tempo de escuta e passa a força como sample_weight; os índices de utilizadores e itens continuam
a ser os do snapshot inteiro, por isso todos continuam a ter recomendações. Se o snapshot não tiver
nenhum tempo de escuta, treina sem pesos (como com a flag desligada) e regista um aviso. Valor vazio
conta como false.
- Lida pelo ML Service (
/train) e pelo Container Apps Job de treino (src.training_job). - Variable Group:
ML_RECOMMENDATION_WEIGHT_BY_SIGNAL(true/false) emaudissey-dev,audissey-stagingeaudissey-prod. Desired-state:deploy/config/appsettings/ml/*.json
(RECOMMENDATION_WEIGHT_BY_SIGNAL) edeploy/config/ml-training-job/*.json
(recommendationWeightBySignal, aplicado e auditado porscripts/ensure-ml-training-job.js).
A variável tem de existir nos três VGs antes do release ML: os scripts recusam placeholders sem valor. - Mudar o valor no VG só tem efeito depois do release ML (ou da reparação da auditoria) e do treino seguinte.
Medir o impacto (só leitura). A partir de apps/ml-service, com MONGO_URI / MONGO_DB_NAME
de dev ou staging no ambiente ou em .env:
python -m scripts.compare_listen_signal --users 50 --top-k 10 # --snapshot-date, --max-rows N
Só corre numa lista fechada de bases: audissey-dev ou audissey-staging, ou qualquer base num
servidor local (todos os hosts do MONGO_URI são localhost / 127.0.0.1, esquema mongodb://)
com --allow-local. Tudo o resto é recusado antes da primeira query, incluindo audissey (a base de
produção, que também é o default da configuração quando falta MONGO_DB_NAME). A mensagem de recusa
nunca mostra o URI nem os hosts.
Treina em memória (1 thread) o modelo sem pesos (flag desligada) e o modelo com pesos (flag ligada)
e imprime só agregados: se o modelo sem pesos saiu do estado inicial, a sobreposição do top-K entre
os dois e o noise floor de cada um (mesmos dados, dois seeds), e quantos itens ouvidos por cada
utilizador aparecem no seu top-K (in-sample: o mesmo snapshot treina e avalia, é otimista).
Medição em audissey-staging (Out 2026, top-10, seeds 42/43):
| Snapshot | Utilizadores | Pares ouvidos | Noise floor sem / com pesos | Com vs sem pesos | Itens ouvidos no top-10 sem / com |
|---|---|---|---|---|---|
| 2026-10-05 | 3 | 4 | 0.000 / 0.133 | 0.033 | 0/4 / 1/4 |
| 2026-10-02 | 3 | 5 | 0.000 / 0.433 | 0.000 | 0/5 / 3/5 |
| 2026-09-25 | 3 | 4 | 0.000 / 0.200 | 0.000 | 0/4 / 3/4 |
| 2026-09-21 | 2 | 5 | 0.000 / 0.350 | 0.000 | 0/5 / 2/5 |
Staging tem 2–3 utilizadores e 1 com escuta por dia (50 000 itens): os números mostram o mecanismo
(o modelo sem pesos é aleatório; com pesos aprende), não a qualidade em produção.
O cross-join cortava utilizadores. Staging tem 10 utilizadores ativos com
userfeatures, mas
até 2026-10-16 cada snapshot só tinha os 3 primeiros: com lotes de 50 e pausa de 1 s cada
utilizador custava ~19 min de escrita e o orçamento de 45 min parava o ciclo depois do 3.º; cada
execução escrevia 1 dia e o cursor ficou ~11 dias atrasado. O 92e5e62a passa a fazer snapshot só de
ontem e leva os lotes do VG (1000 / 100) ao worker. Depois do deploy entram todos, mas 10 × 50 000 =
500 000 linhas por dia é o limite de leitura do treino. Proposta de snapshot só com pares com
interação + catálogo uma vez: ML_SNAPSHOT_SLIM_PROPOSAL.md.
Modelos
Embedding Model
- Default:
paraphrase-multilingual-MiniLM-L12-v2 - Dimensão: 384
- Idiomas: Português, Inglês, Espanhol, etc.
Classification Model
- Default:
neuralmind/bert-base-portuguese-cased - Fine-tuned: Para português BR/PT
- Task: Multi-label genre classification
🔄 CI/CD Pipeline
Pipeline CI (azure-pipelines-ml.yml)
Triggers:
- Push para
main,dev,feature/* - Pull requests para
main,dev - Apenas mudanças em
apps/ml-service/**
Stages:
Lint & Format
- Ruff linting
- Black formatting check
- isort import order
- mypy type checking (optional)
Unit Tests
- pytest com coverage
- Publicar resultados de testes
- Publicar coverage report
Dependency Check
- safety (vulnerabilidades conhecidas)
- pip-audit (audit dependencies)
Rodar Pipeline Localmente
# Simular CI pipeline
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
# Lint
ruff check src/ tests/
black --check src/ tests/
isort --check-only src/ tests/
# Test
pytest --cov=src --cov-report=term
# Security
pip install safety
safety check
📊 Monitoramento
Logs
# src/main.py
import logging
logger = logging.getLogger(__name__)
logger.info("Processing recommendation")
Métricas (Futuro)
- Latência de inferência
- Throughput (requests/sec)
- Acurácia de classificação
- Cache hit rate
Health Check
curl http://localhost:8000/health
🔗 Integração com API Principal
Via HTTP Client (NestJS)
// apps/api/src/app/ml/ml.service.ts
import { HttpService } from '@nestjs/axios';
const response = await this.httpService.post(`${ML_SERVICE_URL}/api/v1/recommend`, {
userId: '123',
contentType: 'podcast',
limit: 10,
});
Via Message Queue (Futuro)
# Processamento assíncrono via BullMQ
📝 Desenvolvimento
Adicionar Nova Feature
Criar branch
git checkout dev git pull origin dev git checkout -b feature/sentiment-analysisImplementar
# Criar módulo touch src/services/sentiment.py touch tests/unit/test_sentiment.pyTestar
pytest tests/unit/test_sentiment.py -vLint
ruff check src/services/sentiment.py black src/services/sentiment.pyCommit
git add . git commit -m "feat(ml): adicionar análise de sentimento" git push -u origin feature/sentiment-analysisPR
- Abrir PR no Azure DevOps
- CI pipeline roda automaticamente
- Aguardar aprovação
Adicionar Dependência
# Adicionar em requirements.txt
echo "scikit-learn==1.4.0" >> requirements.txt
# Instalar
pip install -r requirements.txt
# Testar
pytest
# Commit
git add requirements.txt
git commit -m "chore(deps): adicionar scikit-learn"
🐛 Troubleshooting
Import Errors
# Garantir que venv está ativo
which python # deve apontar para venv/bin/python
# Reinstalar dependências
pip install --force-reinstall -r requirements.txt
Testes Falhando
# Rodar com verbosidade
pytest -vv
# Rodar teste específico
pytest tests/unit/test_config.py::test_settings_defaults -vv
# Debug com pdb
pytest --pdb
Type Errors (mypy)
# Ignorar imports externos
mypy src/ --ignore-missing-imports
# Configurar em pyproject.toml
Pipeline Falhando
- Lint errors: Rodar
ruff check --fixeblacklocalmente - Test failures: Rodar
pytestlocalmente - Dependencies: Verificar
requirements.txtestá atualizado
📚 Recursos
Documentação
Tutoriais
Tools
🤝 Contribuir
Última atualização: 27 de dezembro de 2025
Versão: 0.1.0
Python: 3.11+