HamonizAi
Recomendador de vinhos por harmonização gastronômica — NLP clássico, sem LLMs, com scores 100% auditáveis.
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:
- 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.
- 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":
- 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.
- NLP clássico pro parsing do input.
PhraseMatcherdo spaCy (exato, multi-palavra) primeiro, fallbackrapidfuzztoken-set só se o exato falhar. Tolerante a ordem e typos, rigoroso o suficiente pra rejeitar termos conflitantes. - 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.
- 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
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
oakzera 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/recommendretornando 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.pycom 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
- Demo ao vivo: harmonizai.vercel.app
- GitHub: MarcosNespolo/harmonizai
- Referência: What to Drink with What You Eat — Dornenburg & Page