Marcos Nespolo
Produção

HamonizAi

Recomendador de vinhos por harmonização gastronômica — NLP clássico, sem LLMs, com scores 100% auditáveis.

PythonspaCyrapidfuzzFastAPISQLiteNext.js

Funcional ponta-a-ponta com três interfaces (CLI, API HTTP, web). As melhorias ativas são tuning do scoring e enriquecimento do dataset; o pipeline central está estável.

O problema

As ferramentas de "AI sommelier" hoje caem em dois modos de falha:

  1. Recomendadores só com LLM — você digita o prato, recebe um parágrafo plausível mas potencialmente alucinado. Sem como auditar por que aquele vinho foi escolhido em vez de outro.
  2. Retrieval vetorial sobre reviews — devolve vinhos cujas notas de degustação parecem textualmente similares à query, ignorando as heurísticas estruturais que sommelier de verdade usa (acidez corta gordura, tanino segura proteína, carvalho atrapalha peixe cru).

Quis o oposto: um sistema onde cada componente do score é um número que dá pra ler, onde a lógica prato→vinho está fundada em literatura de sommelier, e onde reprodutibilidade é nativa (mesma entrada → mesma saída, sempre).

A abordagem

Quatro escolhas de design saem de "sem LLM":

  1. Base curada de pratos. 101 pratos mapeados manualmente para atributos (tags Vivino, ranges-alvo de estrutura, keywords de aroma de match/exclusão, tipos sugeridos, estilos proibidos). Validados contra What to Drink with What You Eat (Dornenburg & Page) e guias de sommelier cruzados — cada prato-âncora carrega sua fonte.
  2. NLP clássico pro parsing do input. PhraseMatcher do spaCy (exato, multi-palavra) primeiro, fallback rapidfuzz token-set só se o exato falhar. Tolerante a ordem e typos, rigoroso o suficiente pra rejeitar termos conflitantes.
  3. Score multi-sinal ponderado com punições fatais. Quatro componentes independentes em [0, 1], somados com pesos explícitos — e dois caminhos de penalidade (flavors incompatíveis, estilos proibidos) que zeram um vinho independentemente do resto.
  4. Três interfaces finas sobre um motor. CLI, FastAPI e web Next.js — todas chamando o mesmo RecommendationEngine. O recomendador não sabe nem importa qual UI está perguntando.

Arquitetura

Diagram (mermaid · placeholder)

Pipeline: query em texto livre → FoodMatcher resolve pra dish_id (ou null) usando phrase match exato primeiro, fuzzy depois → RecommendationEngine consulta SQLite com vivino_food_tags, target_structure e flavor keywords do prato → scorer.py calcula quatro componentes por candidato e aplica punições → top-N retornado com breakdown completo. Cada request vai pra tabela harmonization_requests pra análise offline depois.

Decisões técnicas & trade-offs

Por que sem LLM? Essa é a premissa inteira — mas os benefícios práticos são concretos. Custo zero de inferência (roda offline no notebook), reprodutibilidade perfeita, cada recomendação explicável até quatro números, e o projeto mostra NLP clássico e feature engineering em vez de "sei chamar a API da Anthropic."

Por que SQLite com FTS5? O dataset tem 1.688 vinhos depois da dedup. Postgres ou banco vetorial seria overkill, adicionaria complexidade de deploy e ganharia nada. SQLite é embarcado, tem full-text search via FTS5, e roda sem configurar nada.

Por que curar 101 pratos na mão em vez de gerar automático? Qualidade do mapeamento prato→atributo é o teto do sistema inteiro. Mapeamento automático a partir de review reproduz o mesmo viés que retrieval vetorial já tem. Cada prato-âncora foi validado contra pelo menos duas fontes de sommelier; o YAML registra a fonte.

Por que dividir o score em quatro componentes em vez de um modelo? Auditabilidade é o produto. Cada recomendação mostra [Food: 0.85] [Flavor: 0.60] [Struct: 0.90] [Rating: 0.65], então o usuário — ou eu, debugando — vê exatamente qual sinal carregou o match. Um ranker neural pontuaria melhor num benchmark e pior no objetivo real.

Dataset

Scraped da Vivino: 437 JSONs brutos → merge → dedup por wine.id → normalizado em SQLite.

1.688

vinhos únicos

De 437 JSONs brutos depois da dedup

16

países

França, Itália, Portugal, Argentina lideram

92%

cobertura de estrutura

acidez/corpo/tanino/doçura

101

pratos curados

11 cozinhas, validados na literatura

Distribuição por tipo: 1.284 tintos · 264 brancos · 51 espumantes · 44 fortificados · 34 rosés · 11 sobremesa. Cozinhas cobertas: brasileira, italiana, francesa, japonesa, argentina, espanhola, chinesa, tailandesa, indiana, portuguesa, internacional.

Detalhe do scoring

score =
    0.40 × s_food_tags     # match contra wine.style.food
  + 0.15 × s_flavor        # match contra wine.taste.flavor keywords
                           #   (com punição por flavors excluídos)
  + 0.45 × s_structure     # fit de range em corpo/acidez/tanino/doçura
  + 0.01 × s_rating        # só desempate
  − style_penalty          # fatal (-4.0) se cair em dish.avoid_styles

Duas regras duras complementam a soma ponderada:

  • Exclusão de flavor: vinho com oak zera pra sushi (flavor_keywords_exclude: [oak, vanilla, smoke]).
  • Exclusão de estilo: Moscato ou Late Harvest pegam punição fatal em pratos salgados (avoid_styles: [Moscato, Late Harvest]).

Ambas as regras moram em dishes.yaml por prato — então adicionar prato adiciona regras sem mexer no motor.

O que existe

  • CLI com print do breakdown completo (auditável no terminal).
  • FastAPI em POST /api/recommend retornando JSON estruturado com breakdown, imagem do rótulo, link Vivino, link Google Shopping.
  • Web Next.js com cards de skeleton-loader consumindo a API.
  • Extração de intenção de preço (budget/moderate/premium, max_price) parseada da query — já volta na resposta, ainda não plugada no ranking (esperando enriquecimento de preço).
  • Log de requests em SQLite pra análise de tuning offline.

O que eu faria diferente

  • Curar pratos com um sommelier desde o dia 1. Validei contra livros e artigos, mas sommelier real revisando o YAML pegaria edge cases (harmonizações regionais, fusion moderna) mais rápido do que eu.
  • Construir o eval set antes de tunar pesos. Tunei peso lendo top-5 na mão. O tests/test_coverage.py com 50 queries sintéticas veio depois — ter ele antes teria deixado o fix do blockbuster menos anedótico.
  • Não entregar intenção de preço sem dado de preço. O NLP detecta "vinho barato pra sushi" limpo; o dataset não tem preço. Ou entrega o enriquecimento junto, ou deixa a feature fora até ela poder fazer o trabalho dela.

Status & links