Construindo um "Sommelier" Dinâmico: Uma API de Recomendação em C# que Aprende com o Cardápio
Fala, pessoal!
Quem nunca abriu um cardápio (especialmente de cerveja artesanal ou vinho) e ficou completamente perdido com termos como "IBU", "Notas de torra", "Taninos" ou "Acidez"? A paralisia por escolha é real.
Recentemente, me deparei com esse exato problema. A ideia inicial era construir uma API focada apenas em recomendar cervejas artesanais. Mas, como todo projeto de dev, a coisa escalou.
Percebi que a lógica para escolher uma cerveja (Amargor, Sabor, Cor) não era tão diferente da lógica para escolher um prato (Ponto, Tempero, Molho) ou uma sobremesa (Doçura, Temperatura).
E se, em vez de o usuário ler o cardápio, a API "lesse" o cardápio e entrevistasse o usuário?
Hoje, queria compartilhar a arquitetura e os aprendizados de uma API de recomendação genérica que construí em C# (.NET), usando MVC, IMemoryCache e Docker, que transforma qualquer cardápio estruturado em JSON num formulário de recomendação dinâmico.
O Desafio: De Hardcoded para Genérico
O primeiro impulso foi criar um endpoint POST /api/beer/recommend e hardcodar as perguntas: "Você gosta de cerveja amarga?", "Prefere clara ou escura?".
Isso funcionaria. Mas seria péssimo de manter. Se o bar adicionasse uma cerveja nova com uma característica única (ex: "Sabor" = "Defumado"), eu teria que alterar o backend, adicionar a opção, e talvez até recompilar.
O "pulo do gato" foi inverter a lógica: A API não deve saber nada sobre cervejas, carnes ou sobremesas. Ela só deve saber ler JSON.
A API precisava ser capaz de:
- Receber um cardápio qualquer em JSON, desde que estruturado.
- Analisar os itens e suas "características" (tags).
- Gerar um formulário dinâmico com perguntas baseadas nessas características.
- Receber as respostas do usuário.
- Calcular um score para cada item do cardápio original.
- Recomendar o item com maior pontuação para cada categoria.
A Arquitetura: MVC, Cache e o Fluxo de "Sessão"
Optei por uma arquitetura Web API padrão em C# (.NET), seguindo o padrão MVC. A tecnologia-chave para fazer isso funcionar sem um banco de dados complexo foi o IMemoryCache.
Por quê? Porque o processo tem dois passos (dois requests HTTP separados):
- Request 1: O front-end envia o cardápio. A API gera e retorna o formulário.
- Request 2: O front-end envia as respostas. A API precisa lembrar qual era o cardápio original para poder pontuar os itens.
Usar IMemoryCache (nativo do ASP.NET Core) foi a solução mais simples: armazeno o cardápio original e o formulário gerado usando um formId (um Guid) como chave. Quando as respostas chegam com esse formId, eu recupero os dados do cache. Simples, rápido e eficiente para uma "sessão" curta.
Claro, tudo "dockerizado" para facilitar o deploy.
O Fluxo na Prática (e em JSON)
Vamos ver o fluxo de dados.
Passo 1: O Cardápio (Input)
Tudo começa com o front-end (ou outro serviço) enviando o cardápio. O contrato é crucial. Cada item tem uma lista de characteristics, e cada característica tem seu name (o tipo, ex: "Sabor"), seu value (o valor desse item, ex: "Cítrico") e as options (todos os valores possíveis para essa característica).
JSON
{
"restaurant": "Aura Gastronomia",
"menu": {
"categories": [
{
"name": "Cervejas Artesanais",
"items": [
{
"name": "IPA da Casa",
"characteristics": [
{
"name": "Amargor",
"value": "Intenso",
"options": ["Leve", "Moderado", "Amargo", "Intenso"]
},
{
"name": "Sabor",
"value": "Cítrico",
"options": ["Adocicado", "Cítrico", "Torrado", "Frutado", "Maltado"]
}
]
},
{
"name": "Weissbier de Trigo",
"characteristics": [
{
"name": "Amargor",
"value": "Leve",
"options": ["Leve", "Moderado", "Amargo", "Intenso"]
},
{
"name": "Sabor",
"value": "Frutado",
"options": ["Adocicado", "Cítrico", "Torrado", "Frutado", "Maltado"]
}
]
}
]
}
// ... outras categorias (Entradas, Pratos Principais, etc.)
]
}
}
Passo 2: A Mágica - O Formulário Dinâmico (Output 1)
Quando a API recebe o JSON acima, o FormService entra em ação.
A lógica principal está no método CreateQuestionsForCategory. Ele:
- Pega todos os items de uma categoria.
- Usa SelectMany para achatar todas as characteristics de todos os itens.
- Usa GroupBy(c => c.Name) para encontrar as características únicas (ex: "Amargor", "Sabor").
- Embaralha (OrderBy(_ => Guid.NewGuid())) e pega um número X de perguntas (usando o questionLimit opcional).
- Consulta um IQuestionService (um simples Dictionary ou switch) para mapear o nome da característica (ex: "Amargor") para uma pergunta legível (ex: "Qual o nível de amargor que você prefere?").
- Coleta todas as options possíveis para aquela característica.
Aqui um trecho do FormService:
C#
// ...
private List<FormQuestionsViewModel> CreateQuestionsForCategory(List<ItemViewModel> items, int? questionLimit)
{
// Junta TODAS as características de TODAS as cervejas
var allCharacteristics = items
.SelectMany(i => i.Characteristics)
.GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First()) // Mantém apenas uma por nome
.OrderBy(_ => Guid.NewGuid()) // Embaralha
.ToList();
// Aplica o limite máximo, se houver
var limit = questionLimit.HasValue && questionLimit.Value > 0
? Math.Min(questionLimit.Value, allCharacteristics.Count)
: allCharacteristics.Count;
var selectedCharacteristics = allCharacteristics
.Take(limit)
.ToList();
var questions = new List<FormQuestionsViewModel>();
foreach (var characteristic in selectedCharacteristics)
{
var questionText = _questionService.GetQuestionForCharacteristic(characteristic.Name);
if (string.IsNullOrWhiteSpace(questionText)) continue;
questions.Add(new FormQuestionsViewModel
{
Characteristic = characteristic.Name,
Question = questionText,
Options = items // Pega todas as opções possíveis para essa característica no cardápio
.SelectMany(i => i.Characteristics)
.Where(c => c.Name.Equals(characteristic.Name, StringComparison.OrdinalIgnoreCase))
.SelectMany(c => c.Options)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
});
}
return questions;
}
// ...
A API então salva o cardápio original no cache com a chave formId:XYZ e retorna este JSON para o front-end:
JSON
{
"formId": "7711abc2-bc84-4e5f-8701-c610aa481bf4",
"categories": [
{
"name": "Cervejas Artesanais",
"questions": [
{
"characteristic": "Sabor",
"question": "Qual o sabor que você prefere?",
"options": ["Adocicado", "Cítrico", "Torrado", "Frutado", "Maltado"]
},
{
"characteristic": "Amargor",
"question": "Qual o nível de amargor que você prefere?",
"options": ["Leve", "Moderado", "Amargo", "Intenso"]
}
]
}
// ... outras categorias de perguntas
]
}
Passo 3: As Respostas (Input 2)
O usuário preenche o formulário. O front-end monta o JSON de respostas e o envia de volta, referenciando o formId.
JSON
{
"formId": "7711abc2-bc84-4e5f-8701-c610aa481bf4",
"categories": [
{
"name": "Cervejas Artesanais",
"selectedAnswers": [
{
"characteristicAsked": "Sabor",
"selectedOption": "Cítrico"
},
{
"characteristicAsked": "Amargor",
"selectedOption": "Intenso"
}
]
}
]
}
Passo 4: O "Scoring" e a Recomendação (Output Final)
Aqui é onde o RecommendationService brilha pela simplicidade.
- Ele usa o formId para buscar o cardápio original no IMemoryCache.
- Valida se as respostas são compatíveis com o formulário original (para evitar tampering).
- Cria um Dictionary<string, float> itemsScored para "placar".
- Para cada categoria, ele itera em cada item do cardápio.
- Dentro desse loop, ele itera em cada resposta do usuário.
- A Lógica Central: Se a característica do item (itemCharacteristic.Value) for igual à resposta do usuário (answer.SelectedOption), o item ganha +1.0f.
C#
// ...
private RecommendationCategoryViewModel CreateCategoryRecommendation(
string categoryName,
List<SelectedAnswersViewModel> selectedAnswers,
List<ItemViewModel> items)
{
var itemsScored = new Dictionary<string, float>();
foreach (var item in items)
{
float currentItemScore = 0;
foreach (var answer in selectedAnswers)
{
var itemCharacteristic = item.Characteristics
.FirstOrDefault(c => c.Name.Equals(answer.CharacteristicAsked, StringComparison.OrdinalIgnoreCase));
if (itemCharacteristic == null)
continue;
// O "match" simples:
if (itemCharacteristic.Value.Equals(answer.SelectedOption, StringComparison.OrdinalIgnoreCase))
{
currentItemScore += 1.0f;
}
}
itemsScored[item.Name] = currentItemScore;
}
// Pega o item com maior pontuação
var bestItems = itemsScored
.OrderByDescending(kv => kv.Value)
.Take(1) // Recomenda só o top 1
.Select(kv => kv.Key)
.ToList();
// ... monta o ViewModel de retorno
}
// ...
Se o usuário respondeu "Cítrico" e "Intenso", a "IPA da Casa" (Valor: "Cítrico", Valor: "Intenso") ganha 2 pontos. A "Weissbier" (Valor: "Frutado", Valor: "Leve") ganha 0.
A API então retorna a recomendação final:
JSON
{
"categories": [
{
"name": "Cervejas Artesanais",
"items": [
{
"recommendation": "IPA da Casa"
}
]
}
]
}
Lições Aprendidas e Próximos Passos
- O Poder da Generalização: A melhor decisão foi não hardcodar nada sobre cervejas. Ao focar em uma estrutura JSON genérica (characteristic, value, options), a API funciona para qualquer tipo de item. A inteligência não está na API, mas na modelagem dos dados do cardápio.
- IMemoryCache é um Faca de Dois Gumes: Foi ótimo para prototipar e funciona perfeitamente em um cenário de single instance. Porém, isso é um state mantido na memória da aplicação. Se a API estiver rodando em load balance (vários pods no Kubernetes, por exemplo), o Request 2 (respostas) pode cair em um servidor diferente do Request 1 (formulário), que não terá o formId em seu cache. A sessão quebraria.
- O Próximo Passo Óbvio: Cache Distribuído. Para escalar de verdade, o próximo passo é trocar a implementação de ICacheService de IMemoryCache para IDistributedCache, usando Redis. Isso centraliza o cache e torna a API stateless, permitindo o load balance sem problemas.
- Pontuação Simples Funciona: O sistema de score += 1.0f é básico, mas funciona e é 100% previsível. Poderíamos complicar? Sim. Poderíamos usar fuzzy matching (ex: "Cítrico" dá 0.5 pontos para "Frutado") ou até ML. Mas para um "sommelier" de cardápio, o simples resolveu 90% do problema com 10% do esforço.
Conclusão
Foi um projeto divertido que saiu de um problema específico (cervejas) para uma solução de arquitetura genérica e reutilizável. O ponto-chave foi definir um contrato JSON robusto e deixar que os dados (o cardápio) guiassem a lógica, e não o contrário.
E vocês? Já precisaram construir algo parecido? Como lidariam com o state entre os requests? Manteriam a pontuação simples ou investiriam em algo mais complexo?
Deixem aí nos comentários!