Documentation v1 · en

Compre Barato Alagoas

Price comparator for supermarkets, pharmacies, and stores in Alagoas, built on public electronic invoice (NFC-e) data from SEFAZ-AL.

Live app: alagoas.precospublicos.ia.br · Panel: admin.alagoas.precospublicos.ia.br · Code: open source (MIT). This documentation describes what exists in the code today, including an honest limitations section.

Overview

The user types (or speaks) a shopping list in natural language — e.g. "5kg de arroz, 1L de leite, sabão em pó". The app looks up prices near them, computes a fair unit price (per kg, per liter, per unit), and returns stores ordered from cheapest to most expensive, showing how much you save and the date of each sale.

The problem

SEFAZ-AL is currently the only state revenue department in Brazil to publish NFC-e prices publicly and for free. The value is in the data; the obstacle is access. The project's goal is to deliver the same information in a simple, fast, and accessible way for people with low income and limited technical familiarity, in an interface designed for that (large taps, little text).

The hard part: the SEFAZ database has no field for package size ("5kg", "1L"). That information exists only in free-text product descriptions. Without extracting it, you cannot fairly compare products of different sizes. Solving that is the heart of the project — see Normalization.

Architecture

  User (Flutter — Android / web)
          │  HTTPS, only to our API
          ▼
  Backend (FastAPI)  ───►  Public Economiza Alagoas API (SEFAZ-AL)
          │                 (access token lives ONLY on the server)
          ├── interpret the list ............. LLM (Claude Haiku)
          ├── normalize size/unit ............ fair price per kg / L / unit
          ├── rank stores .................... by basket coverage + total
          └── cache + state .................. Redis (required)

The backend is a secure intermediary: it holds the SEFAZ token. The user app never talks to SEFAZ directly and never receives the token. Every app call goes to /api/v1/* on our backend.

FolderContents
backend/FastAPI API: intermediary, normalization, ranking, cache, device identity, metrics.
frontend/Flutter app (Android + web): Riverpod, OpenStreetMap, voice input.
admin-frontend/Static admin panel (HTML + JS), served at admin.<domain>.
docs/This documentation (static), served at docs.<domain>.
deploy/docker-compose + nginx vhosts.

Search flow

Endpoint: POST /api/v1/search · Orchestration: backend/app/services/search_service.py.

  1. List interpretation (LLM): free text becomes structured items (label + search term + quantity). Compound lines ("arroz e feijão") are split; quantities and sizes are stripped from the search term.
  2. Per-item search: one SEFAZ call per item (the API accepts only one criterion per request), using the term as descricao plus geolocation + radius.
  3. Normalization: each returned row becomes an "offer" with price per base unit (kg / L / unit) — see below.
  4. Ranking: offers are grouped by store and stores are ordered by basket coverage and total price.
  5. Cache + list: each item result is cached in Redis and the list gets a UUID for sharing.
Each item is an independent SEFAZ call, so the result is cached by (term, origin, radius, days). Repeated searches are served from cache without a new SEFAZ query.

Fair price (normalization)

Code: backend/app/services/normalization/ (quantity.py, units.py, matcher.py). This is the central component of the project.

Unit-price decision tree

  1. If SEFAZ unidadeMedida is already weight/volume (KG, G, L, ML…), the product is sold bulk and valorVenda is already the price for that unit — we only convert to the canonical base.
  2. Otherwise the price is for a package: we extract size from the description text (extract_quantity) and divide.
  3. If size cannot be determined, we fall back to package price and set quantity_parsed = false — the signal that comparison quality dropped on that line.

Extraction examples

Description (free text)Extracted size
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 units
"mg" is ignored on purpose. In grocery/pharmacy, "500MG" is dosage ("DIPIRONA 500MG"), never package size. Letting it through would turn a 10-tablet box into a meaningless 0.005 kg comparison.

Each offer also carries an extraction method (unidade_medida / description / fallback) and a 0–1 confidence used in panel quality metrics.

Basket ranking

Code: backend/app/services/ranking.py.

For each (store, item) we keep the best-value offer (lowest price per base unit). Stores are ordered by:

  1. Coverage — more of your list items available first (a cheap store missing half the list does not really help);
  2. Cheapest basket — sum of package prices;
  3. Closest — Haversine distance from your origin.
Missing items are flagged, never summed as zero. Each store returns items_found, items_total, and a missing list of items it does not have. The UI shows the cheapest store expanded ("CHEAPEST") and the others collapsed with "+R$ delta".

Data honesty

NFC-e prices reflect recent sales, not necessarily today's. The app is explicit about that:

Cache and usage limits

Redis is required (no in-memory fallback). On startup the app pings Redis and fails fast if it is unavailable (backend/app/cache.py, main.lifespan). Redis holds:

Daily limit: enforce_rate_limit (backend/app/api/deps.py) counts searches per client per day (key ratelimit:{day}:{client}, 24 h TTL) and returns 429 when exceeded. Configurable via DAILY_SEARCH_LIMIT (0 disables).

Analysis of real SEFAZ data patterns (what searches return, timing, size-parse rate, items that never appear, etc.) underpins all future cache optimizations. Admin numbers (quality, timings, top-searched items) are the live rolling version of that. Internal research notes live in the private repository.

Device identity & LGPD (no login)

There are no accounts. The app once generates a 256-bit token (frontend/lib/data/device_identity.dart, stored in secure storage / Keystore) and sends it in the X-Device-Token header. It is treated as a credential — never logged and never stored in cleartext.

EndpointPurpose
POST /api/v1/device/consentRecords LGPD consent (legal basis for saving data in the cloud).
GET /api/v1/device/meShows what the server stores for that device.
DELETE /api/v1/device/meErasure (LGPD): removes everything the server has for the device.

A search from a device with consent has its list saved automatically on the server (history without login). The consent trigger is the "Save lists to the cloud" button (CloudSyncSheet). The policy lives in the app's PolicyScreen and in frontend/web/privacy.html.

By design, there is no portability. Lose the device, lose the server-side data. Most data stays on the device itself; the server only keeps what it needs (consent + saved list ids).

Anonymous usage measurement (unique-device counts for growth metrics) uses an identifier separate from this token, with a legitimate-interest legal basis and opt-out — detailed in the legitimate interest assessment (LIA).

Shared lists

Links use the format …/abrir/<uuid>: the basket is stored on the server, not in the URL (links stay short for any list size). The /abrir path prefix is kept for existing Android App Links.

User feedback

POST /api/v1/feedback (anonymous; optional X-Device-Token). The results screen has a 👍/👎 card and "report wrong item". It is best-effort and never breaks the main flow. Events feed the panel's feedback section.

Admin panel

Static SPA in admin-frontend/, served at admin.<domain>, protected by ADMIN_TOKEN (bearer, constant-time comparison, fail-closed when unset → 401). Data comes from backend/app/analytics.py, Redis-native (no Postgres).

"AI & Product" section

"Technical" section

All metric writes are best-effort and never block a search.

API (endpoints)

MethodRouteDescription
GET/healthStatus + active data sources.
POST/api/v1/searchBasket search (body: items, latitude, longitude, radius_km, days).
GET/api/v1/suggestionsCommon item suggestions.
GET/api/v1/lists/{id}Resolve a shared list.
POST/api/v1/device/consentLGPD consent.
GET/DELETE/api/v1/device/meView / delete device data.
POST/api/v1/feedbackAnonymous feedback.
/admin/api/*Panel metrics (requires ADMIN_TOKEN).

Limits imposed by SEFAZ and enforced by the schema: radius 1–15 km, days 1–10, up to 30 items per search. Interactive docs at /docs (Swagger) in non-production.

Mock mode vs. production

Both external services (SEFAZ and LLM) sit behind a Protocol + factory. Switching between simulated and real is only flipping flags in the root .envno code changes.

Flagtrue (default today)false (production)
USE_MOCK_SEFAZsynthetic Maceió catalog (data/mock_sefaz.json)real SEFAZ-AL API (requires SEFAZ_APP_TOKEN)
USE_MOCK_LLMdeterministic rule-based parserClaude Haiku (requires ANTHROPIC_API_KEY)
The real SEFAZ client (services/sefaz/http_client.py) is the only place the token is attached, and it builds the produto/pesquisa body exactly as the developer manual specifies.

Stack & dependencies

Known limitations

Transparency is a project value. What is still not ready or is simplified today:

ItemStatus
Cross-store product matching (GTIN / LLM adjudication) not done Today we rely on SEFAZ keyword search and group by store; there is no GTIN canonicalization of the "same product" nor LLM disambiguation. Ambiguous terms (e.g. "leite" bringing liquid and powdered milk) can mix variants.
Real SEFAZ data awaiting token Runs in mock mode until the free SEFAZ token is configured.
SEFAZ calls per item sequential A long uncached list makes N serial calls (candidate for parallelization).
Promotion notifications (push) not done Require FCM/Web-Push + a price-monitoring pipeline + live SEFAZ. Device identity is only the foundation.
iOS not configured Flutter base supports it, but only Android and web are configured.
Postgres / pgvector and Langfuse unused Declared for the future (embeddings, LLM tracing), but the app today is Redis-native and does not use them.
Match accuracy evaluation partial There is a quality metric for size extraction; there is not yet a labeled eval of term→product match precision.

Project status