Drop a pile of documents. Get answers. Stay neon.
Drag in hundreds of PDFs, Word files, spreadsheets and more. Each one is turned into text, sent to a
decision model, and stamped with a type (invoice, credit_note, purchase_order, ...) and a
confidence bar. Two models are wired up, and you pick one per batch:
| Model | Where it runs | Confidence score | |
|---|---|---|---|
| Decision-1 | Microsoft-Decision-1 |
Azure AI Foundry deployment | yes, per-type probabilities |
| Jev | ~typesafe/jev-latest (pinnable) |
OpenRouter decisions endpoint | yes, per-type probabilities |
Both are "System One" models: you give them text and a fixed set of options, and they pick one, fast. No chat,
no free text, no hallucinated labels. The answer is always one of your types, or unknown.
There is also an arcade. It started as a joke and then got out of hand. Jump to the arcade.
uv sync
cp .env.example .env # then fill it in (see Configuration)
uv run server.py # http://localhost:8000Open the page, drop in samples_100/ (100 fictional PDFs) and hit Classify. Set PORT to use a free port of your choice.
- Drop files or whole folders, or click to browse. Hundreds at once is fine: files are sent one by one, 1 to 8 in parallel.
- Live results with a progress bar, a scoreboard per type, and a confidence bar per row. Anything under 70% turns red ("needs review").
- Filter by type or errors, and export CSV when done.
- Retries that behave: on a
429every worker pauses together and honours theretry-afterheader, so a rate-limited deployment is not hammered. - Two themes: Neon (above) and 8-Bit, with a switch in the corner. Your choice is remembered.
Supported files: PDF, DOCX, PPTX, XLSX, XLS, HTML, TXT, MD, CSV, JSON. Text is extracted locally with Microsoft's MarkItDown, and the first 30,000 characters go to the model. Scanned PDFs with no text layer produce nothing and would need OCR.
Edit types.json. Each type is an id and a one-line description, and the model chooses among them. unknown is added
automatically as an "I don't know" option, so you don't define it.
{
"types": [
{"id": "invoice", "description": "Request for payment for delivered goods or services"},
{"id": "credit_note", "description": "Correction or reversal of an earlier invoice"}
]
}The same pipeline runs without the UI and writes one JSON line per document:
uv run classify.py ./documents --catalog types.json --output classifications.jsonl
uv run classify.py ./samples --min-confidence 0.70 # flag shaky answers for reviewIt scans folders recursively (--non-recursive to stop that), keeps going when a file fails, and records the error on that
line. --max-chars changes how much text is sent. The CLI uses Decision-1 only.
/game is a Space Invaders clone with three pilots, and you can switch pilots mid-game.
| Pilot | How it plays | Latency per decision |
|---|---|---|
| You | Arrow keys or A/D, Space to fire, P to pause, M for sound | depends on you |
| Model | The game describes the board in words; Decision-1 or Jev answers with a move and a fire decision | about 0.35 to 0.5 s (both models) |
| Algorithm | A classic planner: every frame it simulates the next 1.2 s for 30 candidate positions and plays the first move of the best plan | about 0.1 to 0.2 ms |
The honest result, from one 60 second run each at normal speed, so treat it as a vibe and not a benchmark:
- Algorithm: 1650 points, level 2, no lives lost.
- Decision-1 with the game waiting for it: 250 points.
- Decision-1 racing the clock: about 120 points, then dead.
The models are not dumb, they are late. The game runs at 60 frames a second and an answer takes 30 to 90 frames, and a text description gives them no sense of where a shot will be in half a second. That is why "Game waits for model" exists: it freezes the game while the model thinks, so you can judge its decisions without the lag. The planner needs neither trick, because it can look ahead for free.
The takeaway: these models shine at choosing from a list, like document types. For a 60 fps reflex game, write a planner. Both live in the same page so you can watch the difference.
There is sound too, synthesised in the browser (no audio files): laser, explosions, and the four-note alien march that speeds up as the aliens thin out. Square waves in 8-Bit mode, saw waves in Neon.
Settings come from .env (real environment variables win over it). .env is git-ignored, and .env.example is the template.
| Variable | Needed | What |
|---|---|---|
AZURE_ENDPOINT |
yes | Foundry resource endpoint, e.g. https://YOUR-RESOURCE.services.ai.azure.com |
DEPLOYMENT_NAME |
yes | Name of your Decision-1 deployment |
AZURE_API_KEY |
one of | API key. Leave empty to use Entra ID (az login, scope https://cognitiveservices.azure.com/.default) |
OPENROUTER_API_KEY |
optional | Turns on Jev in the UI |
OPENROUTER_MODEL |
optional | Defaults to ~typesafe/jev-latest. Pin a version such as typesafe/jev-1.13 if you rely on confidence thresholds |
PORT |
optional | Server port, default 8000 |
The Foundry call is POST {AZURE_ENDPOINT}/providers/microsoft/v1/systemone with model set to your deployment name and a
choice question per decision. Jev takes the same request and returns the same response from POST https://openrouter.ai/api/alpha/decisions.
The arcade asks two questions in one call (move and fire).
flowchart LR
B[Browser UI] -->|one file per request| S[FastAPI server]
S --> M[MarkItDown: file to text]
M --> D[Decision-1 on Foundry]
M --> J[Jev on OpenRouter decisions endpoint]
B -->|board description| S
S -->|move and fire question| D
S -->|move and fire question| J
B -.->|planner runs in the page, no network| B
- Rate limits. Decision-1 allows as many requests per minute as the deployment's capacity. A change to the capacity can take a while to
reach the data plane, and until it does you get
429s (the app waits and retries for about 15 minutes before giving up). - Entra vs keys. Entra needs a role that includes the
Microsoft.CognitiveServices/accounts/AIServices/providers/actiondata action, and role changes can lag. An API key sidesteps all of it. - Use the decisions endpoint for Jev, not the router.
typesafe/jev-routeron OpenRouter's chat endpoint is a router that picks an ordinary LLM per request (a test call was answered byz-ai/glm-5.3-flash), so it is not Jev and returns no probabilities. The real Jev is a decisions model on/api/alpha/decisions: about 0.5 s per call, versus 2 to 3 s through the router in a quick comparison. - The decisions endpoint is alpha and may change shape.
- Upload limit is 25 MB per file. The server keeps each upload in a temp folder only while it converts it.
- No tests yet. It was built by trying things and watching them work.
server.py FastAPI app: upload endpoint, arcade endpoint, static files
classify.py Catalog loading, Decision-1 and Jev clients, shared retry logic, the CLI
game_ai.py Turns the arcade's move and fire questions into Decision-1 / Jev calls
static/ index.html (classifier), game.html (arcade), style.css (both themes)
types.json Your document types
samples/ 5 fictional PDFs (4 types plus one unrelated)
samples_100/ Those 5, copied 20 times, for stress testing
make_sample_pdfs.py Regenerates the 5 sample PDFs
docs/ Screenshots used in this README



