Compre Barato Alagoas
Comparador de preços de supermercados, farmácias e lojas em Alagoas, construído sobre os dados públicos de notas fiscais (NFC-e) da SEFAZ-AL.
Visão geral
O usuário digita (ou fala) uma lista de compras em linguagem natural — ex.: "5kg de arroz, 1L de leite, sabão em pó". O app consulta os preços praticados perto dele, calcula o preço justo por unidade (por kg, por litro, por unidade) e devolve uma lista de lojas ordenada da mais barata para a mais cara, mostrando quanto se economiza e a data de cada venda.
- Plataformas: Flutter — uma base de código para Android e web.
- Backend: FastAPI (Python) — intermediário seguro, normalização e ranqueamento.
- Dados: API pública Economiza Alagoas (SEFAZ-AL).
- Estado/cache: Redis (obrigatório).
- IA: Claude Haiku interpreta a lista em linguagem natural.
O problema
A SEFAZ-AL é hoje a única secretaria estadual do país a publicar preços de NFC-e de forma pública e gratuita. O valor está nos dados; o obstáculo é o acesso. O objetivo do projeto é entregar a mesma informação de forma simples, rápida e acessível para um público de baixa renda e baixa familiaridade técnica, em uma interface pensada para isso (português, toques grandes, pouco texto).
Arquitetura
Usuário (Flutter — Android / web)
│ HTTPS, apenas para a nossa API
▼
Backend (FastAPI) ───► API pública Economiza Alagoas (SEFAZ-AL)
│ (o token de acesso fica SÓ no servidor)
├── interpreta a lista ............ LLM (Claude Haiku)
├── normaliza tamanho/unidade ..... preço justo por kg / L / un
├── ranqueia as lojas ............. por cobertura da cesta + total
└── cache + estado ................ Redis (obrigatório)
O backend é um intermediário seguro: é ele quem guarda o token da
SEFAZ. O aplicativo do usuário nunca fala diretamente com a SEFAZ e
nunca recebe o token. Toda chamada do app vai para
/api/v1/* do nosso backend.
| Pasta | Conteúdo |
|---|---|
backend/ | API FastAPI: intermediário, normalização, ranqueamento, cache, identidade de dispositivo, métricas. |
frontend/ | App Flutter (Android + web): Riverpod, mapa OpenStreetMap, entrada por voz. |
admin-frontend/ | Painel administrativo estático (HTML + JS), servido em admin.<domínio>. |
docs/ | Esta documentação (estática), servida em docs.<domínio>. |
deploy/ | docker-compose + vhosts nginx. |
Fluxo de uma busca
Endpoint: POST /api/v1/search · Orquestração:
backend/app/services/search_service.py.
- Interpretação da lista (LLM): o texto livre vira itens estruturados (rótulo + termo de busca + quantidade). Linhas compostas ("arroz e feijão") são separadas; quantidades e tamanhos são removidos do termo de busca.
- Busca por item: uma chamada à SEFAZ por item (a API
aceita só um critério por requisição), usando o termo como
descricaoe a geolocalização + raio. - Normalização: cada linha retornada vira uma "oferta" com preço por unidade base (kg / L / un) — veja abaixo.
- Ranqueamento: as ofertas são agrupadas por loja e as lojas são ordenadas por cobertura da cesta e preço total.
- Cache + lista: o resultado de cada item é cacheado no Redis e a lista ganha um UUID para compartilhamento.
(termo, origem, raio, dias). Buscas repetidas são servidas
do cache sem nova consulta à SEFAZ.Preço justo (normalização)
Código: backend/app/services/normalization/
(quantity.py, units.py, matcher.py).
É o componente central do projeto.
A árvore de decisão do preço por unidade
- Se a
unidadeMedidada SEFAZ já é peso/volume (KG, G, L, ML…), o produto é vendido a granel e ovalorVendajá é o preço por aquela unidade — apenas convertemos para a base canônica. - Caso contrário, o preço é de uma embalagem: extraímos o tamanho do texto
da descrição (
extract_quantity) e dividimos. - Se não dá para determinar o tamanho, caímos no preço por embalagem e
marcamos
quantity_parsed = false— o sinal de que a qualidade da comparação caiu naquela linha.
Exemplos de extração
| Descrição (texto livre) | Tamanho extraído |
|---|---|
| ARROZ BRANCO TIPO 1 PCT 5KG | 5 kg |
| LEITE NA CAIXA 1L | 1 L |
| CAFE A VACUO 250G | 0,25 kg |
| CERVEJA LATA 350ML C/12 | 12 × 350 ml (multipack) |
| OVOS BRANCOS C/12 | 12 un |
Cada oferta carrega ainda um método de extração (unidade_medida /
description / fallback) e uma confiança 0–1, usados nas
métricas de qualidade do painel.
Ranqueamento da cesta
Código: backend/app/services/ranking.py.
Para cada (loja, item) guardamos a melhor oferta por valor (menor preço por unidade base). As lojas são ordenadas por:
- Cobertura — mais itens da sua lista disponíveis primeiro (uma loja barata que não tem metade da lista não ajuda de verdade);
- Cesta mais barata — soma dos preços de embalagem;
- Mais perto — distância (Haversine) a partir da sua origem.
items_found, items_total e a lista
missing com os itens que ela não tem. A interface mostra a loja mais
barata expandida ("MAIS BARATO") e as demais recolhidas com o "+R$ delta".Honestidade dos dados
Preços de NFC-e refletem vendas recentes, não necessariamente de hoje. O app é explícito sobre isso:
- Data por item: cada preço mostra a data em que aquela venda foi registrada
(
sale_date, vindo dedataVenda) — nunca um vago "últimos N dias" global (preços mudam todo dia). - Aviso na tela de resultados: "Cada preço mostra a data em que foi registrado."
- Qualidade da comparação: quando o tamanho não pôde ser extraído, a oferta é marcada como comparação por embalagem (não por kg/L).
Cache e limite de uso
Redis é obrigatório (não há fallback em memória). Na inicialização o app faz
um ping e falha rápido se o Redis não estiver disponível
(backend/app/cache.py, main.lifespan). O Redis guarda:
- cache de busca por item (TTL padrão de 6 h);
- UUIDs das listas compartilháveis (TTL de 30 dias, renovado a cada acesso);
- registros de dispositivo (consentimento LGPD, listas salvas);
- contadores de limite de uso e as métricas do painel.
Limite diário: enforce_rate_limit (backend/app/api/deps.py)
conta as buscas por cliente por dia (chave ratelimit:{dia}:{cliente},
TTL de 24 h) e devolve 429 ao estourar. O valor é configurável via
DAILY_SEARCH_LIMIT (0 desativa).
Identidade de dispositivo & LGPD (sem login)
Não há contas. O app gera uma vez um token de 256 bits
(frontend/lib/data/device_identity.dart, guardado no armazenamento
seguro / Keystore) e o envia no cabeçalho X-Device-Token. É tratado
como uma credencial — nunca é registrado em log nem armazenado em claro.
| Endpoint | Função |
|---|---|
POST /api/v1/device/consent | Registra o consentimento LGPD (base legal para salvar dados na nuvem). |
GET /api/v1/device/me | Mostra o que o servidor guarda para aquele dispositivo. |
DELETE /api/v1/device/me | Apagamento (LGPD): remove tudo que o servidor tem do dispositivo. |
Uma busca feita por um dispositivo com consentimento tem a lista salva
automaticamente no servidor (histórico sem login). O gatilho do consentimento é o
botão "Salvar listas na nuvem" (CloudSyncSheet). A política fica no
PolicyScreen do app e em frontend/web/privacy.html.
A medição anônima de uso (contagem de aparelhos únicos para as métricas de crescimento) usa um identificador separado deste token, com base legal de legítimo interesse e opt-out — detalhada na avaliação de legítimo interesse (LIA).
Listas compartilhadas
Links têm o formato …/abrir/<uuid>: a cesta é guardada no servidor,
não na URL (os links ficam curtos para qualquer tamanho de lista).
POST /searchdevolvelist_id; cestas idênticas reutilizam o mesmo id (deduplicação por hash).GET /api/v1/lists/{id}resolve o uuid de volta para os itens;404(expirou/desconhecido) leva o usuário à tela inicial.- Quem abre o link busca a partir da sua própria localização.
- No Android, App Links abrem o app instalado direto (intent-filter com
pathPrefix /abrir+assetlinks.json).
Feedback do usuário
POST /api/v1/feedback (anônimo; X-Device-Token opcional).
A tela de resultados tem um cartão 👍/👎 e "reportar item errado". É best-effort e
nunca quebra o fluxo de uso. Os eventos alimentam a seção de feedback do painel.
Painel administrativo
SPA estática em admin-frontend/, servida em
admin.<domínio>, protegida por ADMIN_TOKEN (bearer,
comparação em tempo constante, falha fechado quando não configurado → 401).
Os dados vêm de backend/app/analytics.py, nativo de Redis (sem
Postgres).
Seção "IA & Produto"
- Visão geral: total de buscas, usuários únicos estimados (HyperLogLog), taxa de acerto (match rate), custo de LLM.
- Qualidade: taxa de extração de tamanho, distribuição de métodos de parse.
- Custos: custo de LLM por busca (
services/llm/pricing.py; Haiku 4.5 a US$1/US$5 por 1M de tokens). Marcado como "modo mock" até a chave real entrar. - Feedback, buscas por hora, itens mais buscados e não encontrados.
Seção "Técnico"
- Desempenho: histogramas de latência por etapa (total, llm, sefaz, cache, normalize, rank) com p50/p95.
- Provedores: chamadas, erros e latência dos terceiros (
sefaz,llm). - Configurações: cofre de segredos — define/troca o token da SEFAZ, que é criptografado em repouso (Fernet) e nunca aparece em arquivos, logs ou na tela. Veja Segurança & dados.
Toda a gravação de métricas é best-effort e nunca bloqueia uma busca.
API (endpoints)
| Método | Rota | Descrição |
|---|---|---|
| GET | /health | Status + fontes de dados ativas. |
| POST | /api/v1/search | Busca da cesta (corpo: items, latitude, longitude, radius_km, days). |
| GET | /api/v1/suggestions | Sugestões de itens comuns. |
| GET | /api/v1/lists/{id} | Resolve uma lista compartilhada. |
| POST | /api/v1/device/consent | Consentimento LGPD. |
| GET/DELETE | /api/v1/device/me | Ver / apagar dados do dispositivo. |
| POST | /api/v1/feedback | Feedback anônimo. |
| — | /admin/api/* | Métricas do painel (exige ADMIN_TOKEN). |
Limites impostos pela SEFAZ e respeitados pelo schema: raio 1–15 km, dias 1–10,
até 30 itens por busca. Documentação interativa em /docs (Swagger).
Modo mock vs. produção
Os dois serviços externos (SEFAZ e LLM) ficam atrás de um Protocol + factory.
Trocar entre simulado e real é só virar flags no .env da raiz —
sem mudar código.
| Flag | true (padrão hoje) | false (produção) |
|---|---|---|
USE_MOCK_SEFAZ | catálogo sintético de Maceió (data/mock_sefaz.json) | API real da SEFAZ-AL (exige SEFAZ_APP_TOKEN) |
USE_MOCK_LLM | parser determinístico por regras | Claude Haiku (exige ANTHROPIC_API_KEY) |
services/sefaz/http_client.py)
é o único lugar onde o token é anexado, e monta o corpo de
produto/pesquisa exatamente como o manual do desenvolvedor especifica.Stack & dependências
- Backend: FastAPI, Pydantic, httpx, redis. Testes com fakeredis (sem
servidor).
73 testesno backend,21no frontend. - Frontend: Flutter + Riverpod, flutter_map (OpenStreetMap), speech_to_text (voz), geolocator, flutter_secure_storage.
- IA: Claude Haiku 4.5 para interpretar a lista.
- Observabilidade: Sentry (ligado por DSN; no-op sem ele).
- Deploy: docker-compose atrás de nginx + certbot. App, painel e docs em subdomínios próprios.
Limitações conhecidas
Transparência é um valor do projeto. O que ainda não está pronto ou é simplificado hoje:
| Item | Situação |
|---|---|
| Casamento de produto entre lojas (GTIN / adjudicação por LLM) | não feito Hoje confiamos na busca por palavra-chave da SEFAZ e agrupamos por loja; não há canonicalização do "mesmo produto" por GTIN nem desambiguação por LLM. Termos ambíguos (ex.: "leite" trazendo leite líquido e leite em pó) podem misturar variações. |
| Dados reais da SEFAZ | aguardando token Roda em modo mock até o token gratuito da SEFAZ ser configurado. |
| Chamadas à SEFAZ por item | sequencial Uma lista longa, sem cache, faz N chamadas em série (candidato a paralelização). |
| Notificações de promoção (push) | não feito Exigem FCM/Web-Push + pipeline de monitoramento de preços + SEFAZ ao vivo. A identidade de dispositivo é só a base. |
| iOS | não configurado Base Flutter suporta, mas só Android e web estão configurados. |
| Postgres / pgvector e Langfuse | não usados Declarados para o futuro (embeddings, tracing de LLM), mas o app hoje é nativo de Redis e não os utiliza. |
| Avaliação de acurácia de casamento | parcial Há métrica de qualidade de extração de tamanho; ainda não há eval rotulado de precisão do casamento termo→produto. |
Status do projeto
- No ar: app web + API e painel administrativo (em modo mock).
- Para ir a produção com dados reais: preencher
SEFAZ_APP_TOKENeANTHROPIC_API_KEYno.enve virarUSE_MOCK_*=false. - Licença: MIT.