Executando verificação de segurança...
7

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

  1. Request 1: O front-end envia o cardápio. A API gera e retorna o formulário.
  2. 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:

  1. Pega todos os items de uma categoria.
  2. Usa SelectMany para achatar todas as characteristics de todos os itens.
  3. Usa GroupBy(c => c.Name) para encontrar as características únicas (ex: "Amargor", "Sabor").
  4. Embaralha (OrderBy(_ => Guid.NewGuid())) e pega um número X de perguntas (usando o questionLimit opcional).
  5. 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?").
  6. 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.

  1. Ele usa o formId para buscar o cardápio original no IMemoryCache.
  2. Valida se as respostas são compatíveis com o formulário original (para evitar tampering).
  3. Cria um Dictionary<string, float> itemsScored para "placar".
  4. Para cada categoria, ele itera em cada item do cardápio.
  5. Dentro desse loop, ele itera em cada resposta do usuário.
  6. 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!

Carregando publicação patrocinada...
1