Documentação v1 · pt-BR

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.

App online: alagoas.precospublicos.ia.br · Painel: admin.alagoas.precospublicos.ia.br · Código: open source (MIT). Esta documentação descreve o que existe hoje no código, incluindo uma seção honesta de limitações.

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.

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

A parte difícil: a base da SEFAZ não tem um campo com o tamanho da embalagem ("5kg", "1L"). Essa informação só existe no texto livre da descrição do produto. Sem extraí-la, não dá para comparar produtos de tamanhos diferentes de forma justa. Resolver isso é o coração do projeto — veja Normalização.

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.

PastaConteú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.

  1. 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.
  2. Busca por item: uma chamada à SEFAZ por item (a API aceita só um critério por requisição), usando o termo como descricao e a geolocalização + raio.
  3. Normalização: cada linha retornada vira uma "oferta" com preço por unidade base (kg / L / un) — veja abaixo.
  4. Ranqueamento: as ofertas são agrupadas por loja e as lojas são ordenadas por cobertura da cesta e preço total.
  5. Cache + lista: o resultado de cada item é cacheado no Redis e a lista ganha um UUID para compartilhamento.
Cada item é uma chamada independente à SEFAZ, então o resultado é cacheado por (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

  1. Se a unidadeMedida da SEFAZ já é peso/volume (KG, G, L, ML…), o produto é vendido a granel e o valorVenda já é o preço por aquela unidade — apenas convertemos para a base canônica.
  2. Caso contrário, o preço é de uma embalagem: extraímos o tamanho do texto da descrição (extract_quantity) e dividimos.
  3. 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 5KG5 kg
LEITE NA CAIXA 1L1 L
CAFE A VACUO 250G0,25 kg
CERVEJA LATA 350ML C/1212 × 350 ml (multipack)
OVOS BRANCOS C/1212 un
"mg" é ignorado de propósito. Em mercearia/farmácia, "500MG" é dosagem ("DIPIRONA 500MG"), nunca o tamanho da embalagem. Deixá-lo passar transformaria uma caixa de 10 comprimidos em uma comparação sem sentido de 0,005 kg.

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:

  1. Cobertura — mais itens da sua lista disponíveis primeiro (uma loja barata que não tem metade da lista não ajuda de verdade);
  2. Cesta mais barata — soma dos preços de embalagem;
  3. Mais perto — distância (Haversine) a partir da sua origem.
Itens faltantes são sinalizados, nunca somados como zero. Cada loja retorna 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:

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:

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.

EndpointFunção
POST /api/v1/device/consentRegistra o consentimento LGPD (base legal para salvar dados na nuvem).
GET /api/v1/device/meMostra o que o servidor guarda para aquele dispositivo.
DELETE /api/v1/device/meApagamento (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.

Por design, não há portabilidade. Perdeu o aparelho, perdeu os dados do servidor. A maior parte dos dados continua no próprio dispositivo; o servidor só guarda o que precisa (consentimento + ids das listas salvas).

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

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"

Seção "Técnico"

Toda a gravação de métricas é best-effort e nunca bloqueia uma busca.

API (endpoints)

MétodoRotaDescrição
GET/healthStatus + fontes de dados ativas.
POST/api/v1/searchBusca da cesta (corpo: items, latitude, longitude, radius_km, days).
GET/api/v1/suggestionsSugestões de itens comuns.
GET/api/v1/lists/{id}Resolve uma lista compartilhada.
POST/api/v1/device/consentConsentimento LGPD.
GET/DELETE/api/v1/device/meVer / apagar dados do dispositivo.
POST/api/v1/feedbackFeedback 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.

Flagtrue (padrão hoje)false (produção)
USE_MOCK_SEFAZcatálogo sintético de Maceió (data/mock_sefaz.json)API real da SEFAZ-AL (exige SEFAZ_APP_TOKEN)
USE_MOCK_LLMparser determinístico por regrasClaude Haiku (exige ANTHROPIC_API_KEY)
O cliente real da SEFAZ (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

Limitações conhecidas

Transparência é um valor do projeto. O que ainda não está pronto ou é simplificado hoje:

ItemSituaçã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