Skip to main content
Glama
README.md
# Varto

**An autonomous analyst for Ukrainian public procurement.**

Varto continuously watches [Prozorro](https://prozorro.gov.ua), Ukraine's open public-procurement system, reads the tender documentation that humans currently read by hand, and produces a reasoned **go / no-go** recommendation — escalating anything uncertain to a person instead of guessing.

The name is a Ukrainian double meaning: *варта* — a watch or guard, and *чи варто* — "is it worth it".

**Live:** <https://varto-ai.vercel.app>

---

## Status

Honest snapshot, because a portfolio repository that overstates itself is worse than one that doesn't exist.

| Component | State |
|---|---|
| **Prozorro MCP server** | ✅ Working — usable today from any MCP client |
| Typed API client + schemas | ✅ Working, validated against recorded live responses |
| Document reader (`.pdf`, `.docx`) | ✅ Working, with explicit handling for scans and legacy formats |
| Deterministic pre-filter (Gate) | ✅ Working, 7 rules, zero token cost |
| Ingestion pipeline → Postgres | ✅ Working — running in production against the live feed |
| Scheduler | ⚙️ Ships as a migration — Postgres `pg_cron` calls the endpoint once its two Vault secrets are set (see below) |
| Public feed page | ✅ Working — shows what the Gate let through, with links back to Prozorro |
| LLM analysis chain, verdicts, human review queue | ⏳ Planned |

**There are no AI verdicts yet.** Nothing in this codebase calls a language model. What runs today is the deterministic half: the crawler, the schema-validated API client, the rule-based Gate, and the page that shows the result. The reasoning stages in the diagram below are designed but not built.

104 tests, clean `tsc --noEmit`.

---

## What works today: the Prozorro MCP server

A [Model Context Protocol](https://modelcontextprotocol.io) server exposing Ukraine's open procurement data as five tools. Point any MCP-compatible client at it and ask about live tenders.

```bash
npm install
npm run mcp
```

To use it from an MCP client, add the server to its configuration. For **Claude Desktop** this is `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`, Windows: `%APPDATA%\Claude\`); for **Cursor** it is `.cursor/mcp.json` in the project or `~/.cursor/mcp.json` globally. Both use the same shape:

```json
{
  "mcpServers": {
    "prozorro": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/varto/src/mcp/server.ts"]
    }
  }
}
```

Use an absolute path, and restart the client afterwards. The server speaks stdio and needs no environment variables — Prozorro's data is open by law, so there is no API key and no registration.

| Tool | Purpose |
|---|---|
| `search_tenders` | Walk the Prozorro change feed |
| `get_tender` | Full tender object by id |
| `get_tender_card` | Normalised summary: title, CPV, value, buyer, deadline |
| `list_documents` | A tender's attached documents |
| `read_document` | Extracted text, or an explicit reason why it could not be read |

---

## Running it locally

```bash
npm install
cp .env.example .env   # then fill in the Supabase values
npm run dev            # http://localhost:3000
```

Only `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` are required; everything else has a working default compiled into `src/lib/config.ts`, documented in `.env.example`. The MCP server needs none of them.

Database schema lives in `supabase/migrations/` and is applied with the Supabase CLI:

```bash
npx supabase db push --db-url "$DIRECT_URL"
```

Every migration is written to be safe to apply twice.

---

## How the scheduler works

```
   Supabase Postgres                              Vercel (fra1)
┌────────────────────────────┐            ┌────────────────────────────┐
│ pg_cron   every 5 minutes  │            │ GET /api/cron/scout        │
│    │                       │            │   requires Bearer secret   │
│    └─ pg_net http_get ─────┼── HTTPS ──►│   walks the change feed    │
│         URL + secret read  │            │   applies the Gate         │
│         from Vault         │            │   writes tenders + reasons │
│                            │◄── SQL ────┼─────────┘                  │
└────────────────────────────┘            └────────────────────────────┘
```

The schedule lives in Postgres rather than in `vercel.json` because Vercel's Hobby plan caps cron jobs at one run per day, which is far too slow to keep up with the change feed. `pg_cron` has minute granularity, `pg_net` makes the outbound HTTPS call, and Supabase is already a hard dependency — so this adds no new service.

The endpoint is a plain authenticated `GET`. It refuses to run without `CRON_SECRET` set and refuses to run when the bearer token does not match: a crawler that spends API quota and writes to a database must fail closed, not open.

Neither the URL nor the secret appears in the migration — `supabase/migrations/` is public. Both live in Supabase Vault and are read at call time:

```sql
select vault.create_secret('https://<your-deployment>/api/cron/scout', 'scout_endpoint_url');
select vault.create_secret('<the same CRON_SECRET that is set on Vercel>', 'scout_cron_secret');
```

Create those two secrets first, then apply `supabase/migrations/0003_scout_schedule.sql` with `db push`. Applied before the secrets exist, the job will run and fail loudly every five minutes — deliberately, because a schedule that quietly does nothing is worse than one that complains.

The cadence and the page size are one decision, not two. The feed carries ~60,700 modifications a day, so `SCOUT_PAGE_LIMIT × ticks-per-day` has to stay above that: 500 every five minutes is 144,000 a day, a 2.4x margin. The same page every ten minutes would be 72,000 — nominally more than the feed produces, but a thin enough margin that a modest tick failure rate would push the cursor backwards. Change one number and recompute the other.

To confirm the job is actually firing — not merely that it exists:

```sql
select status, return_message, start_time from cron.job_run_details
where jobid = (select jobid from cron.job where jobname = 'scout-feed-poll')
order by start_time desc limit 5;

select status_code, left(content::text, 300), created
from net._http_response order by created desc limit 5;
```

A `401` there means the secret in Vault and the one on Vercel have drifted apart.

### Why the function runs in Frankfurt

Vercel places functions in Washington, D.C. (`iad1`) by default. Prozorro is in Ukraine and the database is in `eu-central-1`, so every feed read and every write crossed the Atlantic twice; a full page of the change feed exceeded the 60-second function limit and was killed mid-loop, which meant the cursor never advanced and the next run repeated the same page forever. `vercel.json` pins the function to `fra1`, next to the database. The page size is `SCOUT_PAGE_LIMIT` — an environment variable rather than a constant, precisely because its correct value depends on where the code runs.

---

## What the data actually looks like

Every design decision below came from measuring the live API, not from assuming. Three assumptions turned out to be wrong, and each would have quietly broken the product.

**PDF is not the format of Ukrainian tender documentation.**
A census of 400 documents across 99 tenders:

| Format | Share of all documents |
|---|---|
| `.docx` | 49.3% |
| `.p7s` (detached signatures) | 29.0% |
| `.doc` | 10.0% |
| `application/octet-stream` | 6.0% |
| **`.pdf`** | **3.5%** |

Narrowed to documents actually tagged as tender documentation, **94% are `.docx`/`.doc` and effectively none are PDF**. A PDF-only reader would have been blind to almost everything that matters. Varto reads `.docx` and `.pdf`, flags legacy `.doc` as needing a human, and treats `.p7s` as a signature rather than a document.

**Prozorro reports `Content-Type: text/plain` for every file, whatever it actually is.** File type is therefore detected from content magic bytes, never from the response header and never from the filename.

**Scanned PDFs with no text layer exist but are rare** — 1 in 146 sampled documents. They are detected explicitly and surfaced as "needs a human" rather than silently returning empty text, which would invite a model to hallucinate over nothing.

**The change feed is bigger than it looks.** Roughly 60,700 modifications a day pass through it. Asking Prozorro for the full record of each one was never going to keep up; requesting the status inline with the feed discards about 85% of items before any per-item fetch, which is what makes a single small function able to out-run the feed.

---

## Architecture

The pipeline is ordered so the cheapest stages discard the most work. Three of them use no language model at all.

```
Prozorro change feed
   │
 Scout        plain code, 0 tokens     ─→ local index          ← built
 Gate         plain code, 0 tokens     ─→ rejected, with the reason recorded   ← built
 Triage       small model              ─→ rejected, with the reason recorded
 DocPicker    plain code, 0 tokens     ─→ picks 2–4 documents out of a dozen
 Locator      small model              ─→ picks the sections worth reading
 Analyst      long-context model       ─→ requirements + verbatim citations
 Risk ×2      two different families   ─→ disagreement ⇒ escalate to a human
 Arbiter      judgement model          ─→ go / no-go + confidence
   │
   └─ uncertain, over budget, or a red flag ⇒ human review queue
                                             └─ every human decision becomes a labelled eval example
```

Three decisions worth calling out:

**Two risk assessments from different model families.** A model that is wrong is usually confident, so self-reported confidence is a weak signal. Two independent families disagreeing is a cheap, honest indicator that a case is genuinely hard — and it routes to a human automatically.

**Nothing is discarded silently.** Every stage records why it decided what it decided. A rejected tender keeps the rule that rejected it and a human-readable detail; a tender that could not be read at all is recorded as a failure rather than skipped, because the cursor moves on regardless and the trace has to outlive the logs.

**The agent never submits a bid.** It analyses and recommends; submitting is a human action. That is a product decision, not a missing feature.

---

## Tech stack

TypeScript · Next.js on Vercel · Supabase (Postgres, `pg_cron`, `pg_net`, Vault) · OpenRouter · Model Context Protocol · Vitest · Zod

Model selection lives in configuration, never hardcoded, and every model was verified with a live call before being trusted — two of the first four candidates turned out to be unusable, including one model ID that does not exist at all.

---

## Development

```bash
npm install
npm test           # 104 tests
npm run typecheck  # tsc --noEmit, must be clean
npm run build      # next build
npm run mcp        # start the MCP server over stdio
```

Three conventions this repository holds to:

- **Tests are checked for whether they can actually fail.** A test that passes identically against code where the feature is absent is treated as a defect, not as coverage. Several were found and replaced.
- **Passing tests are not evidence that the code compiles.** Vitest strips types without checking them, so `tsc --noEmit` is a separate gate on every change.
- **Comments carry measurements, and stale measurements are bugs.** More than one defect here traced back to a comment that had quietly stopped being true.

Unit tests never touch the network. The reconnaissance scripts under `scripts/` do, and are marked as not being tests.

Interface text is Ukrainian; code, identifiers and documentation are English.

---

## Licence

MIT