🤖

Content Features para IA/ML

Content Features para IA/ML

Status: ✅ Implementado
Data: Dezembro 2024
Versão: 1.0

Visão Geral

Sistema de normalização e extração de features de conteúdos (rádios, podcasts, episódios, programas) preparado para machine learning e sistemas de recomendação. Todos os metadados são normalizados para padrões ISO e estruturados de forma consistente para facilitar treino de modelos de IA.

Objetivos

  1. Normalização de Metadados: Converter categorias/tags diversas em gêneros padronizados
  2. Extração de Features: Keywords, idiomas, países seguindo padrões ISO
  3. Preparação para ML: Estrutura pronta para embeddings de texto e áudio
  4. Performance: Índices otimizados para queries de recomendação
  5. Escalabilidade: Suporte para milhões de documentos com paginação

Arquitetura

Schema: ContentFeatures

Localização (source of truth): content-features/src/lib/schemas/content-features.schema.ts

Nota: no API, os ficheiros em apps/api/src/app/common/*/content-features.* são shims (re-exports) para @audissey/content-features.

{
  // Identificação
  contentType: 'radio' | 'podcast' | 'episode' | 'program',
  contentId: ObjectId, // Referência ao documento original

  // Metadados Normalizados
  title: string,
  description: string,
  genres: NormalizedGenre[], // gêneros padronizados
  primaryGenre?: NormalizedGenre,
  language: string, // ISO 639-1 (pt, en, es, etc.)
  country: string, // ISO 3166-1 alpha-2 (PT, BR, US, etc.)

  // Features Temporais
  duration: number, // segundos
  publicationDate: Date,
  broadcastTime: string, // "09:00", "21:30"
  broadcastDays: string[], // ["monday", "tuesday", ...]

  // Features de Popularidade
  popularity: number,
  playsTotal: number,
  listenTimeMsTotal: number,
  favoritesCount: number,
  viewCount: number,
  listenCount: number,
  averageRating: number,
  ratingCount: number,

  // Relacionamentos
  parentId: ObjectId, // Rádio/Podcast pai
  parentType: ContentType,

  // Features para ML
  embeddings: {
    text: number[], // Text embeddings (futuro)
    audio: number[], // Audio embeddings (futuro)
    lastUpdated: Date
  },

  // Search & Discovery
  tags: string[],
  keywords: string[], // Extraídas automaticamente
  contentUrl: string,
  imageUrl: string

  // Estado / metadados técnicos
  isActive: boolean,
  lastVerified?: Date,
  metadata?: Record<string, unknown>
}

Gêneros Normalizados

25+ categorias cobrindo os principais tipos de conteúdo:

// Ver enum completo em `@audissey/content-features`
// Exemplos:
// - NEWS = 'news'
// - MUSIC_POP = 'pop'
// - MUSIC_ROCK = 'rock'
// - MUSIC_ELECTRONIC = 'electronic'
// - SPORTS = 'sports'
// - SOCIETY = 'society'
// - OTHER = 'other'

Serviços

ContentNormalizationService

Responsabilidade: Normalizar metadados diversos para padrões consistentes.

Localização (source of truth): content-features/src/lib/services/content-normalization.service.ts

Funcionalidades

  1. Normalização de Gêneros (normalizeGenres)

    • Mapeia 100+ variações PT/EN para gêneros padronizados
    • Exemplos:
      • "notícias", "news", "informação" → news
      • "música pop", "pop music" → pop
      • "desporto", "sports", "esportes" → sports
  2. Normalização de Idiomas (normalizeLanguage)

    • Converte para ISO 639-1
    • "portuguese", "português" → pt
    • "english", "inglês" → en
    • "spanish", "español" → es
  3. Normalização de Países (normalizeCountry)

    • Converte para ISO 3166-1 alpha-2
    • "Portugal", "portuguese" → PT
    • "Brasil", "Brazil" → BR
    • "United States", "USA" → US
  4. Extração de Keywords (extractKeywords)

    • Remove stopwords (PT/EN)
    • Filtra palavras curtas (< 3 chars)
    • Limita a 20 keywords mais relevantes
    • Converte para lowercase
  5. Normalização de Horários (normalizeBroadcastTime)

    • Converte para formato "HH:MM"
    • Valida horários (00:00 - 23:59)
  6. Normalização de Dias (normalizeBroadcastDays)

ContentFeaturesService

Responsabilidade: CRUD de features normalizadas.

Localização (source of truth): content-features/src/lib/services/content-features.service.ts

Métodos Principais

// Upsert por tipo de conteúdo
upsertRadioFeatures(data: RadioFeaturesData): Promise<ContentFeaturesDocument>
upsertPodcastFeatures(data: PodcastFeaturesData): Promise<ContentFeaturesDocument>
upsertEpisodeFeatures(data: EpisodeFeaturesData): Promise<ContentFeaturesDocument>
upsertProgramFeatures(data: ProgramFeaturesData): Promise<ContentFeaturesDocument>

// Busca e filtros
findFeatures(filters: ContentFeaturesFilters): Promise<ContentFeaturesDocument[]>

// Atualização de embeddings (futuro)
updateEmbeddings(contentType: ContentType, contentId: string, embeddings: object): Promise<void>

// Incremento de métricas
incrementViewCount(contentType: ContentType, contentId: string): Promise<void>
incrementListenCount(contentType: ContentType, contentId: string): Promise<void>

// Estatísticas agregadas
getStatistics(): Promise<ContentFeaturesStats>

Endpoints Admin

Base URL: /api/admin/content-features
Autenticação: JWT Bearer Token (obrigatório)

1. Estatísticas Gerais

GET /api/admin/content-features/stats
Authorization: Bearer <token>

Resposta:

{
  "total": 1000,
  "byType": {
    "episode": 1000,
    "podcast": 50,
    "radio": 25
  },
  "byGenre": {
    "news": 300,
    "talk": 250,
    "music": 150
  },
  "byLanguage": {
    "pt": 600,
    "en": 400
  },
  "byCountry": {
    "PT": 500,
    "BR": 300,
    "US": 200
  }
}

2. Busca com Filtros

GET /api/admin/content-features/search?contentType=episode&genres=news&language=pt&limit=10
Authorization: Bearer <token>

Query Parameters:

Resposta: Array de ContentFeaturesDocument

3. Detalhes de Conteúdo Específico

GET /api/admin/content-features/:contentType/:contentId
Authorization: Bearer <token>

Exemplo:

GET /api/admin/content-features/episode/694afaeb7948ae57c35bcf87

Resposta: ContentFeaturesDocument completo

Migration Script

Execução

npm run migrate:content-features

O que faz:

  1. Conecta à base de dados
  2. Busca todos os conteúdos ativos (rádios, podcasts, episódios)
  3. Para cada documento:
    • Extrai metadados originais
    • Normaliza gêneros, idiomas, países
    • Extrai keywords do título/descrição
    • Cria/atualiza documento em content_features
  4. Exibe estatísticas: sucesso/erros por tipo

Saída Exemplo:

🚀 Starting ContentFeatures migration...
================================================

📻 Processing Radios...
   Processed 50/100 radios...
✅ Radios completed: 100/100

🎙️ Processing Podcasts...
   Processed 50/200 podcasts...
✅ Podcasts completed: 200/200

🎧 Processing Episodes...
   Processed 500/1000 episodes...
✅ Episodes completed: 1000/1000

================================================
📊 MIGRATION STATISTICS
================================================
📻 Radios:   100/100 (0 errors)
🎙️ Podcasts: 200/200 (0 errors)
🎧 Episodes: 1000/1000 (0 errors)
================================================
✅ Total Success: 1300
✨ Migration completed successfully!

Configuração

Localização: apps/api/src/scripts/migrate-content-features.ts

Batch Size:

Progresso:

Índices MongoDB

Índices otimizados para queries de recomendação:

// Índice único composto (previne duplicados)
{ contentType: 1, contentId: 1 } // unique: true

// Índices para filtros
{ contentType: 1, genres: 1 }
{ contentType: 1, language: 1, country: 1 }
{ parentId: 1, parentType: 1 }
{ isActive: 1, contentType: 1 }

// Índices para search
{ title: 1 }
{ keywords: 1 }

Uso em Produção

1. População Inicial

# Executar migration para popular features de conteúdos existentes
npm run migrate:content-features

2. Atualização Contínua

Integrar nos processors existentes:

// Em sync-podcasts.processor.ts
async processSyncPodcasts() {
  // ... sincronizar podcasts ...

  // Atualizar features
  for (const podcast of newPodcasts) {
    await this.contentFeaturesService.upsertPodcastFeatures({
      _id: podcast._id,
      title: podcast.title,
      description: podcast.description,
      categories: podcast.categories,
      language: podcast.language,
      // ...
    });
  }
}

3. Sistema de Recomendação

// Exemplo: Recomendar episódios similares
async getRecommendations(episodeId: string, limit = 10) {
  // Buscar features do episódio atual
  const currentFeatures = await this.contentFeaturesService.findFeatures({
    contentType: 'episode',
    contentId: episodeId
  });

  // Buscar episódios com gêneros similares
  const similar = await this.contentFeaturesService.findFeatures({
    contentType: 'episode',
    genres: currentFeatures[0].genres,
    language: currentFeatures[0].language,
    isActive: true
  });

  // Ordenar por popularidade
  return similar
    .sort((a, b) => b.listenCount - a.listenCount)
    .slice(0, limit);
}

ML Training Data Pipeline (US-87)

Objetivo

Serviço de agregação diária que cruza UserFeatures × ContentFeatures para produzir a coleção ml_training_data, usada como input direto pelo modelo de recomendação.

Coleção `ml_training_data`

Cada documento é um snapshot diário de um par (user, content):

Campo Tipo Origem
snapshotDate string (ISO date) Data do snapshot
userId string UserFeatures
lastActiveHour number UserFeatures.dailyBehaviorMetrics
lastActiveDayOfWeek number UserFeatures.dailyBehaviorMetrics
playsByContentType Map<string, number> UserFeatures.dailyBehaviorMetrics
playsByContent Map<string, number> UserFeatures.dailyBehaviorMetrics
listenTimeMsByContent Map<string, number> UserFeatures.dailyBehaviorMetrics
skipsByContent Map<string, number> UserFeatures.dailyBehaviorMetrics
userMostActiveHour number UserFeatures
userPreferredGenres string[] UserFeatures
contentType string ContentFeatures
contentId string ContentFeatures
title string ContentFeatures
genres string[] ContentFeatures
primaryGenre string ContentFeatures
language string ContentFeatures
country string ContentFeatures
popularity number ContentFeatures
playsTotal number ContentFeatures
listenTimeMsTotal number ContentFeatures
favoritesCount number ContentFeatures

Índice único: { snapshotDate, userId, contentType, contentId } — garante idempotência.

Dia das métricas: as linhas de um utilizador levam a entrada de UserFeatures.dailyMetrics com date === snapshotDate. Sem essa entrada (utilizador sem atividade nesse dia), userMostActiveHour e userPreferredGenres vêm da entrada anterior mais recente, e playsByContentType, listenTimeMsByContent e skipsByContent ficam vazios: as interações de outro dia não são repetidas. Entradas posteriores a snapshotDate nunca são usadas. Antes, sem entrada exata, usava-se a entrada mais recente, mesmo de um dia posterior: com o cursor 11 dias atrasado em staging (2026-10-16), um snapshot antigo levava escutas de dias seguintes.

Agendamento

Cron Serviço DB Target Descrição
0 2 * * * UserFeaturesAggregation core Atualiza métricas diárias do utilizador
30 2 * * * ContentPopularityAggregation ops Recalcula popularidade do conteúdo
30 3 * * * MlTrainingDataService ops (writes) / core (reads) Gera snapshot user × content

O serviço corre às 03:30 AM (Europe/Lisbon), garantindo que UserFeatures (core DB, atualizado às 02:00) e ContentFeatures (ops DB, atualizado às 02:30) já estão prontos.

Tempo de escuta nas agregações

listenTimeMsByContent (UserFeatures), userGenreScores[].listenTimeMs e listenTimeMsTotal (ContentFeatures) somam o tempo reportado em play_ended e em play_skipped (tempo ouvido no item que o utilizador deixou ao mudar de rádio/conteúdo). O play_skipped só credita tempo quando o evento vem de uma app que reporta segmentos (appVersion >= LISTEN_SEGMENT_MIN_APP_VERSION, guardado na ingestão a partir de X-App-Version), para não contar de novo valores acumulados enviados por versões antigas; o número de saltos (skipsByContent) não muda. Nos dois, o play_ended de apps com segmentos credita só listenDurationMs (não o tempo de relógio, que contaria pausas e tempo já reportado); versões antigas mantêm o relógio play_started → play_ended (no UserFeatures, só sem listenDurationMs explícito), creditado uma vez por play_started. Detalhes, limites e como verificar em EVENTS_API.md.

O ml-service lê listenTimeMsByContent no treino com a mesma chave <tipo>:<id>; hoje o fit do LightFM ignora esses pesos (sem sample_weight), ver ML_SERVICE_SETUP.md.

Database Connection Mapping

Model / Collection Mongoose Connection Database (staging) Autoscale Max
UserFeatures default (core) audissey-staging 4,000 RU/s
ContentFeatures OPS audissey-staging-ops 1,000 RU/s
MlTrainingData OPS audissey-staging-ops 1,000 RU/s
UserEmbedding OPS audissey-staging-ops 1,000 RU/s
ContentEmbedding OPS audissey-staging-ops 1,000 RU/s

⚠️ Atenção: ml_training_data e content_features partilham o budget de 1,000 RU/s do ops DB com mais ~17 outras colecções. O job content-popularity (02:30) escreve no mesmo ops DB, podendo causar contenção se se estender para além das 03:30. Ver ML_TRAINING_SCALABILITY.md para análise detalhada de capacidade.

Cursor-based Backlog

Cosmos DB Throttle-Safe Writes

The cross-join of ContentFeatures × UserFeatures generates a very high write volume.
All write settings are tuneable via environment variables so dev (low-RU) and prod can use different values:

Env Var Default Description
ML_TRAINING_BATCH_SIZE 50 Initial docs per insertMany (adaptive may lower this at runtime)
ML_TRAINING_BATCH_DELAY_MS 1000 Initial pause between batches (ms)
ML_TRAINING_MAX_RETRIES 20 Max retries on Cosmos DB 16500
ML_TRAINING_INITIAL_RETRY_MS 2000 Initial exponential backoff (ms)
ML_TRAINING_TTL_DAYS 30 Days to keep snapshot docs
ML_TRAINING_MAX_USERS 0 Cap users per run (0 = unlimited)

ML_TRAINING_BATCH_SIZE and ML_TRAINING_BATCH_DELAY_MS are mapped in deploy/config/appsettings/worker/*.json from the Variable Groups (Oct 2026: 1000 / 100 in all environments). Before, the VG values never reached the worker, which ran on the defaults.

Backoff is exponential and capped at 30 s (must stay below KEDA pollingInterval of 30 s to avoid spurious scale-down). ordered: false preserves partial successes; only failed doc indexes are retried.

Adaptive Rate Limiting

The snapshot uses adaptive rate limiting that self-tunes to the available RU budget:

This means the configured ML_TRAINING_BATCH_SIZE and ML_TRAINING_BATCH_DELAY_MS are starting values (ceilings), not fixed rates. On low-RU environments (dev/staging ops DB at 1,000 RU/s shared), the adaptive limiter will quickly converge to a sustainable throughput without manual tuning.

For prod (higher RU budget), override via Container App env vars:

ML_TRAINING_BATCH_SIZE=200
ML_TRAINING_BATCH_DELAY_MS=200
ML_TRAINING_MAX_RETRIES=12
ML_TRAINING_INITIAL_RETRY_MS=3000

For staging (ops DB 1,000 RU/s shared), recommended values:

ML_TRAINING_BATCH_SIZE=50
ML_TRAINING_BATCH_DELAY_MS=1000
ML_TRAINING_MAX_USERS=500

With adaptive rate limiting these defaults are fine — the system will self-throttle down if needed.

📊 Para análise detalhada de capacidade e escalabilidade, ver ML_TRAINING_SCALABILITY.md.

Ficheiros

apps/worker/src/app/ml-training-data/
├── schemas/
│   └── ml-training-data.schema.ts      # Schema Mongoose
├── ml-training-data.module.ts           # Module NestJS
├── ml-training-data.service.ts          # Cron service
└── ml-training-data.service.spec.ts     # Testes unitários

apps/worker/src/app/jobs/
├── jobs.controller.ts                    # HTTP endpoints (manual trigger → BullMQ)
├── schemas/
│   └── job-execution-log.schema.ts       # Execution log schema
└── processors/
    └── snapshot-ml-training.processor.ts # BullMQ processor

Admin Portal

Índices de diagnóstico do Admin

Antes de publicar estas páginas, aplicar em dev → staging → prod e confirmar o
build dos índices Atlas versionados userfeatures {updatedAt:-1,_id:-1},
listeninghistories {playedAt:-1,_id:-1} e
content_features {updatedAt:-1,_id:-1}. Criá-los fora do pico: o build inicial
faz scan e consome CPU/IO, cada B-tree aumenta storage e a manutenção acrescenta
write amplification (updatedAt em updates; playedAt em inserts/alterações).
O impacto esperado é baixo a médio, mas não pode ser quantificado sem
cardinalidade/tamanho de chave, tier e billing Atlas. O desired-state local não
prova que o build remoto já ocorreu.

Roadmap Futuro

Fase 1: Embeddings de Texto ✅ (Preparado)

Fase 2: Embeddings de Áudio

Fase 3: Modelo de Recomendação

Fase 4: Analytics Avançado

Performance

Índices

Capacidade

Otimizações Aplicadas

  1. Índices compostos para queries frequentes
  2. Lean queries (sem hidratação Mongoose)
  3. Projection de campos essenciais
  4. Batch processing na migration

Troubleshooting

Snapshot stuck / cursor not advancing (Error 16500)

Symptom: snapshot-ml-training has 0% success rate; cursor stays at the same date.

Root Cause: Cosmos DB RU throttling (Error 16500 / TooManyRequests) exhausts all retries during insertMany.

Diagnosis:

  1. Check execution logs via Admin Portal → Ingestion Status → snapshot-ml-training
  2. Query Log Analytics (KQL):
    ContainerAppConsoleLogs_CL
    | where Log_s contains "SnapshotMlTraining" and Log_s contains "16500"
    | order by TimeGenerated desc
    | take 50
    

Fix:

KEDA kills worker mid-snapshot (scale-to-zero)

Symptom: Snapshot starts, logs show retry backoff, then container is terminated with ManuallyStopped / KEDAScaleTargetDeactivated.

Root Cause: KEDA monitors only bull:<queue>:wait Redis lists. Once a job moves to :active, KEDA no longer sees it. If all :wait lists are empty during a polling cycle, KEDA scales the worker to 0 — killing any in-progress job.

Mitigations (all deployed):

  1. KEDA :active rule — a 13th KEDA scale rule (keda-redis-snapshot-ml-training-active) monitors bull:snapshot-ml-training:active with listLength=1. While the job is processing, KEDA sees the :active item and keeps the replica alive.
  2. Manual trigger routed through BullMQ — POST /api/admin/ml-training/trigger-snapshot now enqueues a BullMQ job (visible to KEDA) instead of running in-process.
  3. Retry cap reduced — MAX_RETRY_DELAY_MS set to 30 s (below KEDA pollingInterval of 30 s) so the longest sleep during retries doesn't fall entirely within a single KEDA polling window.

KEDA config per environment (all Container Apps workers):

Setting Value
pollingInterval 30
cooldownPeriod 300
minReplicas 0
maxReplicas 2
Scale rules 13 (12 :wait + 1 :active)

Erro: "language override unsupported: unknown"

Causa: Text index do MongoDB não suporta "unknown" como idioma.

Solução:

# Drop da collection e recriar
db.content_features.drop()
npm run migrate:content-features

Migration com muitos erros

Verificar:

  1. Conexão à base de dados
  2. Credenciais MongoDB
  3. Documentos originais têm campos necessários
  4. Logs de erro detalhados

Performance lenta

Otimizar:

  1. Verificar índices: db.content_features.getIndexes()
  2. Analisar queries: .explain("executionStats")
  3. Aumentar batch size na migration
  4. Usar projeção de campos

Recursos Adicionais

Ficheiros Relevantes

apps/api/src/app/
├── common/
│   ├── schemas/
│   │   └── content-features.schema.ts      # Schema MongoDB
│   └── services/
│       ├── content-normalization.service.ts # Normalização
│       └── content-features.service.ts      # CRUD
├── admin/
│   ├── admin.controller.ts                 # Endpoints
│   └── admin.module.ts                     # Module config
└── scripts/
    └── migrate-content-features.ts          # Migration script

docs/features/
└── CONTENT_FEATURES_ML.md                   # Este documento

Comandos Úteis

# Build
npx nx build api

# Migration
npm run migrate:content-features

# Verificar dados
mongosh "mongodb://admin:admin123@localhost:27017/audissey?authSource=admin"
> db.content_features.countDocuments()
> db.content_features.findOne()

# Testar endpoints
curl -H "Authorization: Bearer <token>" \
  http://localhost:3000/api/admin/content-features/stats

Conclusão

O sistema ContentFeatures fornece uma base sólida para machine learning e recomendações, com:

✅ Metadados normalizados e consistentes
✅ Estrutura preparada para embeddings
✅ Performance otimizada com índices
✅ Migration automatizada
✅ Endpoints RESTful documentados
✅ Escalável para milhões de documentos
✅ Adaptive rate limiting para Cosmos DB throttle-safe writes
✅ Análise de escalabilidade documentada (ML_TRAINING_SCALABILITY.md)

Pronto para integração com modelos de IA! 🚀