Arce

Market intelligence on Uruguay’s public procurement.

RoleDesign, development and infrastructure, end to endOriginGrupo CPS, at management’s requestYear2025–2026CodePublic on GitHub
TypeScriptNode.jsExpressPostgreSQLZodOllamaAnthropicReactDocker
Auditable

Every AI answer is stored together with the exact figures it saw. Any conclusion can be traced back to the number behind it.

Arce gathers the history of Uruguay’s public purchases (what was awarded, to whom, at what price and which agency bought it) and turns it into comparable statistics: which products are worth selling, at what price bids are won and who the competition is. An AI model reads those figures and writes the analysis for management, without computing anything on its own.

Arce started out doing something else: reading the state procurement portal (ARCE) every 15 minutes and telling the company which open aluminum tenders were worth bidding on. That part still runs.

Then management asked a bigger question: not which tender is useful today, but what is worth selling to the State, at what price and against whom. Open tenders in one field cannot answer that. It takes the full history of awards, for every product, compared honestly.

What I had to work around.

No data yet

Bulk download of the history will be a separate service. This system had to be ready to receive it and produce the statistics on its own, with no manual work, as soon as batches arrive.

Public data, but messy

Pesos, dollars and indexed units; kilos, liters and reams; prices with and without taxes; cancelled tenders; suppliers that sometimes come without a tax ID. Comparing that without normalizing is comparing noise.

Millions of rows

With the full history, rankings cannot be computed on every query.

Numbers that hold up

The conclusions go to management to decide what to sell. A figure with no source, or an AI that makes things up, is useless.

The numbers come from SQL. The AI interprets them, it does not compute them.

The tempting route was to hand the data to a model and ask for conclusions. But a model that computes can be wrong without anyone noticing, and there is no way to reproduce how it got to a number.

So the statistics are computed in PostgreSQL and the model only receives a package with the figures already done. It answers with a fixed schema and cites numbers, the products it mentions are checked against the package, and every answer is stored with what it saw.

Why it is built this way.

Tolerant contract, strict structure

The ingestion API accepts what the portal shows (decimal commas, dd/mm/yyyy dates, the currency as written) and normalizes it. But an item with no number or an award with no supplier is rejected with the error detail, per tender, without dropping the whole batch.

Each tender travels whole

A tender arrives with its items, bids and awards and replaces its previous version in one transaction. The raw payload is stored with a hash: repeats are not reprocessed and, if the rules change, everything is rebuilt from the raw data without scraping again.

Compare only what is comparable

Everything in Uruguayan pesos before tax, using the exchange rate of each award’s date. Prices are compared only within the same unit, without cancelled tenders, and with medians and percentiles instead of averages.

Precomputed statistics

Eight materialized views in PostgreSQL, built on one row per award. They refresh on their own a minute after the last batch and every night, without blocking the dashboard.

An opportunity index in plain sight

A 0–100 score per product combining volume, frequency, how many agencies buy it and how spread it is across suppliers. The weights are one line of SQL and the dashboard shows all four components.

Same model, local or in the cloud

The analyst uses the provider the rest of the system uses: Ollama on CPU (one to three minutes per report) or the Anthropic API (seconds). The data package is trimmed to each model’s context.

What I ruled out along the way.

Letting the AI compute

Faster to build, but the numbers cannot be reproduced or audited. The model reads figures; it does not produce them.

Averages

A single mistyped price on the portal drags the whole statistic. Medians and percentiles absorb it.

A trained scoring model

With no real data yet, a visible formula that is tuned in one line is more honest than a black box. The real market has to be seen first.

One endpoint per entity

Items, bids and awards sent separately made the scraper’s job harder and made it hard to resend a batch without duplicating anything.

From raw data to a recommendation backed by figures.

  1. 01

    Ingestion API

    Receives batches of historical tenders with a token, validates them against a contract published as JSON Schema and stores the raw payload.

  2. 02

    Normalization

    Suppliers by tax ID, articles by ARCE catalog code, agencies, currencies and units brought to a single canon.

  3. 03

    What to sell

    Products ranked by opportunity index, with the breakdown of each score.

  4. 04

    At what price

    Winning price distribution per product and unit, monthly trend and, where the portal publishes the bid comparison, how much cheaper the winning bid was.

  5. 05

    Who wins and who buys

    Suppliers with their share, win rate and who they compete with; agencies with what they buy most.

  6. 06

    The AI analyst

    A market report for management, a verdict per product (enter, explore or avoid, with an entry price) and answers to free-form questions, always citing the data.

The first stage still lives in the same service: the field monitor reads the portal every 15 minutes, analyzes tender documents with AI and leaves them in a review inbox. What is missing is the history scraper; meanwhile, the dashboard was tested with three years of synthetic purchases sent through the same API.

How it all fits together, in a diagram you can move around.

Four views with draggable boxes and a step-by-step walkthrough: the full circuit from the portal to the dashboard, how a batch comes in and gets normalized, how an award becomes a statistic and how the AI writes the analysis without making up figures.

How the data flows, from the batch to the dashboard.

01

History scraper (pending)

A separate service that walks the portal and sends batches of tenders through the API.

02

Ingestion

Token, Zod contract, raw payload with a hash and one transaction per tender.

03

Normalized PostgreSQL

Agencies, suppliers, articles, tenders, items, bids, awards and exchange rates.

04

Materialized views

One row per award in pesos before tax and, on top of it, prices, rankings and competition.

05

Analyst (Ollama or Anthropic)

Reads a package of figures and answers with validated JSON, stored next to the package.

06

API and dashboard

Express and React: what to sell, products, competitors and buyers.

It lives in the same Docker service as the field monitor, with its own module and database schema: the original pipeline was left untouched.

What is up and running.

Ready for data

as soon as the scraper sends batches, the statistics refresh on their own, with no manual steps.

No made-up figures

the products the AI mentions are checked against the data it received; those that do not exist are dropped.

Visible coverage

the dashboard shows how much of the data has a comparable price and what was left out, and why.

Tested end to end

with three years of synthetic purchases through the same API and an integration test against a real database.

The dashboard, with demo data.

Arce dashboard: products ranked by opportunity index and the AI report
What to sell: products ranked by opportunity index, with its four components, and the report the AI writes from those figures. Synthetic demo data.
Product page in Arce with the AI verdict
A product page: the AI verdict with an entry price based on the percentiles, the competitors that matter and who to sell to first. Synthetic data.
Competitor page in Arce
A competitor page: how much it wins per month, what it sells, to whom and at what win rate. Synthetic data.
  • When the question changes, the data that matters changes: going from "is this tender for us?" to "what is worth selling?" moved the center of the system from the tender document to the award.
  • The hard part of comparing prices is not the statistics but the normalization: currency, unit and taxes decide whether a number means anything.
  • Separating who computes from who interprets lets the AI add value without becoming a source of unbacked figures.
  • Building the receiver before the scraper, and testing it with synthetic data through the same door, validated the contract before the first real record.

Want to talk about this project?

The code is public. If you want to discuss the design or talk about a role, write to me.