Skip to content

Categorizing bank transactions with lazycontroller

This page is a notebook

It was run once and saved with its outputs. Open it on GitHub or download it to run it yourself.

A fast decision model (Jev) categorizes the transactions it is sure about; the rest go to the slow model (GPT-6 Luna), as they would without lazycontroller. Both are reached through OpenRouter, so one key is enough.

Run it from the repository root, without adding Jupyter to the project:

uv run --with jupyterlab --with matplotlib --env-file .env jupyter lab examples/transactions.ipynb

with OPENROUTER_API_KEY=... in .env. The last section runs the same thing locally with Ollama and is skipped if Ollama isn't running.

import os
from typing import Literal

from pydantic import BaseModel, Field

from lazycontroller import FastConfig, OpenAI, Route, ShadowReport

FAST = "typesafe/jev-1.14"
SLOW = "openai/gpt-6-luna"
KEY = os.environ["OPENROUTER_API_KEY"]

routes: list[Route] = []
client = OpenAI(
    base_url="https://openrouter.ai/api/v1",  # the slow side: any OpenAI-compatible API
    api_key=KEY,
    max_retries=5,  # set to handle OpenRouter rate limiting: the SDK waits and retries
    fast=FastConfig(  # the fast side: a System One decision model
        base_url="https://openrouter.ai/api",
        api_key=KEY,
        model=FAST,
        min_confidence=0.9,  # every field must reach it, or the call goes to the slow model
        on_route=routes.append,
    ),
)


class Transaction(BaseModel):
    category: Literal[
        "groceries",
        "dining",
        "transport",
        "shopping",
        "subscriptions",
        "utilities",
        "income",
        "transfer",
    ] = Field(description="What the money was spent on, or where it came from")


def categorize(line: str, client=client):
    completion = client.chat.completions.parse(
        model=SLOW,
        messages=[
            {"role": "system", "content": "Categorize this bank transaction."},
            {"role": "user", "content": line},
        ],
        response_format=Transaction,
    )
    return completion.choices[0].message.parsed.category, completion.model

1. One call, two possible answerers

The code is what you would write with openai. Who answered shows in completion.model, and the Route says why.

for line in ["NETFLIX.COM 866-579-7172 CA", "GITHUB, INC. SAN FRANCISCO"]:
    category, model = categorize(line)
    r = routes[-1]
    print(f"{line:<30} -> {category:<14} by {model:<30} {r.reason}, confidence {r.confidence:.2f}")
NETFLIX.COM 866-579-7172 CA    -> subscriptions  by typesafe/jev-1.14-20261014     confident, confidence 1.00
GITHUB, INC. SAN FRANCISCO     -> subscriptions  by openai/gpt-6-luna              low_confidence, confidence 0.77

2. Shadow mode: compare both models before trusting the fast one

With shadow=True both models answer every call and you always get the slow answer. Each route records the fast model's confidence and whether the two agreed, which is what you need to pick min_confidence.

The list mixes clear transactions with ambiguous ones: a payment to a person, a refund between friends, a monthly transit pass (transport or subscription?), a phone bill (utilities or subscription?). Sorted by confidence, the pattern is easy to see.

TRANSACTIONS = [
    "NETFLIX.COM 866-579-7172 CA",
    "AMZN MKTP IT*2K4L8 LUXEMBOURG",
    "POS 4412 ESSELUNGA MILANO",
    "UBER *TRIP HELP.UBER.COM",
    "SEPA CT ACME SRL STIPENDIO OTTOBRE",
    "PAYPAL *XYZSTORE 4029357733",
    "ENEL ENERGIA SDD 00123456",
    "POS 1234 BAR DA MARIO MI",
    "GITHUB, INC. SAN FRANCISCO",
    "GIROCONTO A FAVORE DI ROSSI MARIO",
    # harder ones
    "APPLE.COM/BILL ITUNES.COM",
    "PAYPAL *STEAM GAMES",
    "AUTOGRILL VILLORESI EST",
    "PRELIEVO BANCOMAT 3241",
    "TARI COMUNE DI MILANO",
    "SUMUP *LUCA B",
    "SATISPAY *MARIO ROSSI",
    "BONIFICO DA BIANCHI LUCA RIMBORSO CENA",
    "ATM MILANO ABBONAMENTO MENSILE",
    "IKEA FOOD MILANO",
    "SDD VODAFONE ITALIA",
    "CASHBACK CARTA VISA",
]

shadow = client.with_fast(shadow=True)
results = []
for line in TRANSACTIONS:
    category, _ = categorize(line, shadow)
    results.append((line, category, routes[-1]))

for line, category, r in sorted(results, key=lambda x: -x[2].confidence):
    verdict = "agree" if not r.shadow_mismatches else "DIFFER"
    fast = r.fast_output["category"]
    print(f"{r.confidence:.2f}  {verdict:<6}  {line:<40} fast: {fast:<14} slow: {category}")
1.00  agree   NETFLIX.COM 866-579-7172 CA              fast: subscriptions  slow: subscriptions
0.99  agree   AMZN MKTP IT*2K4L8 LUXEMBOURG            fast: shopping       slow: shopping
0.99  agree   UBER *TRIP HELP.UBER.COM                 fast: transport      slow: transport
0.99  agree   PAYPAL *XYZSTORE 4029357733              fast: shopping       slow: shopping
0.99  agree   ENEL ENERGIA SDD 00123456                fast: utilities      slow: utilities
0.98  agree   POS 4412 ESSELUNGA MILANO                fast: groceries      slow: groceries
0.97  agree   GIROCONTO A FAVORE DI ROSSI MARIO        fast: transfer       slow: transfer
0.97  agree   APPLE.COM/BILL ITUNES.COM                fast: subscriptions  slow: subscriptions
0.90  agree   POS 1234 BAR DA MARIO MI                 fast: dining         slow: dining
0.86  agree   AUTOGRILL VILLORESI EST                  fast: dining         slow: dining
0.85  agree   SEPA CT ACME SRL STIPENDIO OTTOBRE       fast: income         slow: income
0.82  agree   PRELIEVO BANCOMAT 3241                   fast: transfer       slow: transfer
0.78  agree   GITHUB, INC. SAN FRANCISCO               fast: subscriptions  slow: subscriptions
0.76  agree   PAYPAL *STEAM GAMES                      fast: shopping       slow: shopping
0.76  DIFFER  ATM MILANO ABBONAMENTO MENSILE           fast: subscriptions  slow: transport
0.71  DIFFER  SDD VODAFONE ITALIA                      fast: subscriptions  slow: utilities
0.62  agree   TARI COMUNE DI MILANO                    fast: utilities      slow: utilities
0.59  DIFFER  BONIFICO DA BIANCHI LUCA RIMBORSO CENA   fast: dining         slow: transfer
0.44  agree   CASHBACK CARTA VISA                      fast: income         slow: income
0.42  agree   IKEA FOOD MILANO                         fast: dining         slow: dining
0.33  DIFFER  SUMUP *LUCA B                            fast: shopping       slow: dining
0.18  DIFFER  SATISPAY *MARIO ROSSI                    fast: subscriptions  slow: transfer
report = ShadowReport(r for _, _, r in results)
print(report.table())
22 shadow calls

confidence band   calls  agreement
  0.90-1.00           9  9/9 (100%)
  0.70-0.90           7  5/7 (71%)
  0.00-0.70           6  3/6 (50%)

min confidence   fast takes      agreement
  0.50           18/22 (82%)     15/18 (83%)
  0.60           17/22 (77%)     15/17 (88%)
  0.70           16/22 (73%)     14/16 (88%)
  0.80           12/22 (55%)     12/12 (100%)
  0.90           9/22 (41%)      9/9 (100%)

ShadowReport.curve() gives coverage and agreement for every min_confidence from 0 to 1, ready to plot. The fast model's share of the traffic falls as min_confidence rises; agreement climbs to 100% and stays there.

try:
    import matplotlib.pyplot as plt
except ImportError:
    print("Plotting needs matplotlib: start Jupyter with `uv run --with matplotlib ...`.")
else:
    curve = report.curve()
    fig, ax = plt.subplots(figsize=(7, 3.5))
    ax.plot(
        [p.min_confidence for p in curve], [p.coverage for p in curve], label="fast model takes"
    )
    ax.plot(
        [p.min_confidence for p in curve if p.taken],
        [p.agreement for p in curve if p.taken],
        label="agreement with slow model",
    )
    ax.set_xlabel("min_confidence")
    ax.set_ylim(0, 1.05)
    ax.legend()
    plt.show()

png

Finally, ask for the min_confidence that keeps the agreement you want. suggest_min_confidence() returns the lowest value at which agreement stays at or above the bar, from there up, and refuses to answer from too few calls (min_samples, 20 by default; this example has only 22, so it's lowered here).

for bar in (1.0, 0.95, 0.9):
    t = report.suggest_min_confidence(min_agreement=bar, min_samples=10)
    if t is None:
        print(f"agreement >= {bar:.0%}: not enough data")
        continue
    p = report.at(t)
    print(f"agreement >= {bar:.0%}: min_confidence {t:.2f}, fast model takes {p.taken}/{p.total}")
agreement >= 100%: min_confidence 0.78, fast model takes 13/22
agreement >= 95%: min_confidence 0.78, fast model takes 13/22
agreement >= 90%: min_confidence 0.76, fast model takes 15/22

Agreement isn't correctness: in several of the disagreements above either answer is defensible, and the exact numbers move a little from run to run. Look at the disagreements, and collect more than a handful of calls, before choosing.

3. A different min_confidence for different code paths

with_fast derives a client with other FastConfig settings, like with_options does for the SDK.

cautious = client.with_fast(min_confidence=0.99)
for name, c in [("default 0.9", client), ("cautious 0.99", cautious)]:
    category, model = categorize("POS 1234 BAR DA MARIO MI", c)
    print(f"{name:<14} -> {category:<8} by {model}")
default 0.9    -> dining   by typesafe/jev-1.14-20261014
cautious 0.99  -> dining   by openai/gpt-6-luna

4. Calls that aren't decisions are left alone

A free-text field needs a generative model, so the whole call goes to the slow model untouched.

class Explained(Transaction):
    reason: str = Field(description="One sentence on why")


completion = client.chat.completions.parse(
    model=SLOW,
    messages=[{"role": "user", "content": "Categorize: NETFLIX.COM 866-579-7172 CA"}],
    response_format=Explained,
)
print(completion.choices[0].message.parsed)
print(routes[-1].reason, "-", routes[-1].detail)
category='subscriptions' reason='NETFLIX.COM is a streaming service subscription.'
ineligible - reason: free-text string

5. Everything local, with Ollama

ollama pull kev:0.8b-q8_0 && ollama pull qwen3.5:4b. A local server needs no key. Small local decision models are far less sure of themselves, so most calls stay on the slow side.

import httpx2

try:
    httpx2.get("http://localhost:11434/api/version", timeout=2)
except httpx2.HTTPError:
    print("Ollama isn't running: skipped.")
else:
    local_routes: list[Route] = []
    local = OpenAI(
        base_url="http://localhost:11434/v1",
        fast=FastConfig(
            base_url="http://localhost:11434",
            model="kev:0.8b-q8_0",
            min_confidence=0.6,
            on_route=local_routes.append,
        ),
    )
    for line in TRANSACTIONS[:5]:
        completion = local.chat.completions.parse(
            model="qwen3.5:4b",
            messages=[
                {"role": "system", "content": "Categorize this bank transaction."},
                {"role": "user", "content": line},
            ],
            response_format=Transaction,
            reasoning_effort="none",
        )
        r = local_routes[-1]
        category = completion.choices[0].message.parsed.category
        print(
            f"{line:<36} -> {category:<14} by {completion.model:<14} confidence {r.confidence:.2f}"
        )
NETFLIX.COM 866-579-7172 CA          -> subscriptions  by qwen3.5:4b     confidence 0.36
AMZN MKTP IT*2K4L8 LUXEMBOURG        -> transfer       by qwen3.5:4b     confidence 0.56
POS 4412 ESSELUNGA MILANO            -> dining         by qwen3.5:4b     confidence 0.32
UBER *TRIP HELP.UBER.COM             -> transport      by kev:0.8b-q8_0  confidence 0.69
SEPA CT ACME SRL STIPENDIO OTTOBRE   -> transfer       by qwen3.5:4b     confidence 0.54