Budget vs Actual: a Report with SQL and a Local AI
An organisation with a budget — own revenue, a project subsidy and expenses — wants one question answered every month: where are we against the plan? Here we build a "budget vs actual" report: SQL calculates the monthly plan, the actuals, the variances and the subsidy share; a local model writes only a draft comment; the program makes the PDF; a person approves. The example is invented: "Primerna Ltd."
make (PDF of 18,254 bytes) and approve all passed. psql was not on the host, so we loaded through docker exec. On our machine the kagami-gate guard stops the process with SIGSTOP, so we ran it with nohup. Measured: the PDF in ~35 s (with Ollama and the guard’s pauses), temperature up to 69 °C. Bulgarian: the model’s comment was not saved, so its Bulgarian quality was not assessed; the structure works — SQL supplies the numbers, the model only comments, the PDF is built with Jinja2 and WeasyPrint. Still unchecked: the quality of the Bulgarian comment, how the PDF looks with other fonts, scheduling (cron, n8n).01What you'll learn
- How to arrange a budget by month and actuals by entry in PostgreSQL so that one query returns the whole report.
- How to read a signed variance, and why the sign is reversed for expenses.
- Why the flag looks at both the month and the year to date — on an example with a late subsidy.
- How to get a comment from a local model by schema and catch numbers that are not from the data.
- How to make a PDF with Cyrillic text and how to make "approval" more than a word.
02Before you start
- PostgreSQL — for example the one from lesson 04-01 — and Ollama with a language model you have already pulled (
ollama pull <model>). The model is your choice; quality in your language depends on it — try and compare. - Python 3.10 or newer and the packages
pip install httpx psycopg2-binary jinja2 weasyprint(WeasyPrint 70.0 per PyPI as of 03.10.2026; run by us with Python 3.10). - WeasyPrint needs system libraries (Pango and others) — the installation is described in its documentation. You also need a font with Cyrillic: in the example it is "DejaVu Sans"; check with
fc-list | grep -i dejavu. - Ollama address: the script reads
OLLAMA_URL(defaulthttp://localhost:11434). Do not open Ollama to the network for this lesson. - The lesson follows the same arrangement as "A Monthly Accounting Report with a Local AI" (04-178): numbers from SQL, text as a draft, checks by code and a person.
- The data is invented. A real budget and subsidy agreements are often confidential — only summarised numbers go to the model.
03Steps
-
What we will build
What Who does it Why Plan, actual, variance, subsidy share One query to PostgreSQL Numbers must not depend on the mood of a model A control on the entries Code An amount that lands nowhere must not vanish silently A short comment A local model — a draft It writes fast, but it can be wrong and invent causes PDF and checksum Code The file can be checked later Approval and sending A person The responsibility for the report is theirs -
Tables: a budget line, a monthly plan, actuals
budget_linessays what a line is:revenue_own(own revenue),revenue_subsidy(subsidy) orexpense.budget_plankeeps the plan by month — so a subsidy that comes in instalments is planned in the months when it is expected, not "evenly".actualsare the booked amounts — always positive; the kind of the line decides the sign.report_logis the log of reports. All amounts are in euros.sql · schema.sqlCREATE TABLE budget_lines ( id serial PRIMARY KEY, year int NOT NULL, code text NOT NULL, name text NOT NULL, kind text NOT NULL CHECK (kind IN ('revenue_own', 'revenue_subsidy', 'expense')), UNIQUE (year, code) ); -- The plan, month by month. A year-to-date plan is the sum of the months up to now. CREATE TABLE budget_plan ( line_id int NOT NULL REFERENCES budget_lines(id), month date NOT NULL CHECK (month = date_trunc('month', month)), planned_eur numeric(14,2) NOT NULL CHECK (planned_eur >= 0), PRIMARY KEY (line_id, month) ); -- What really happened: one row per booked amount (always positive; the line says if it is revenue or expense). CREATE TABLE actuals ( id bigserial PRIMARY KEY, line_id int NOT NULL REFERENCES budget_lines(id), booked_on date NOT NULL, amount_eur numeric(14,2) NOT NULL CHECK (amount_eur >= 0), note text ); CREATE INDEX ON actuals (booked_on); CREATE TABLE report_log ( id bigserial PRIMARY KEY, period date NOT NULL, pdf_path text NOT NULL, sha256 text NOT NULL, status text NOT NULL CHECK (status IN ('draft', 'approved')), created_at timestamptz NOT NULL DEFAULT now(), approved_at timestamptz, approved_by text ); -
Invented data
Year 2026, six lines, actuals from January to September with small swings around the plan. Two deliberate "traps": the September subsidy payment has not arrived, and there is a one-off equipment cost in materials.
sql · seed.sql-- INVENTED data for an invented small organisation, 2026. All amounts in euros. INSERT INTO budget_lines (year, code, name, kind) VALUES (2026, 'R1', 'Training fees', 'revenue_own'), (2026, 'R2', 'Consulting', 'revenue_own'), (2026, 'S1', 'Project grant', 'revenue_subsidy'), (2026, 'E1', 'Staff', 'expense'), (2026, 'E2', 'Premises and utilities', 'expense'), (2026, 'E3', 'Materials', 'expense'); -- Plan: the same amount every month, for the whole year. INSERT INTO budget_plan (line_id, month, planned_eur) SELECT l.id, m::date, CASE l.code WHEN 'R1' THEN 2000 WHEN 'R2' THEN 1500 WHEN 'S1' THEN 3000 WHEN 'E1' THEN 4200 WHEN 'E2' THEN 1200 WHEN 'E3' THEN 600 END FROM budget_lines l CROSS JOIN generate_series(date '2026-01-01', date '2026-12-01', interval '1 month') AS m WHERE l.year = 2026; -- Actuals January-September: a small, repeatable swing around the plan. INSERT INTO actuals (line_id, booked_on, amount_eur) SELECT l.id, (m + interval '14 days')::date, round(CASE l.code WHEN 'R1' THEN 2000 WHEN 'R2' THEN 1500 WHEN 'S1' THEN 3000 WHEN 'E1' THEN 4200 WHEN 'E2' THEN 1200 WHEN 'E3' THEN 600 END * (0.90 + 0.04 * ((extract(month FROM m)::int * ascii(l.code)) % 6)), 2) FROM budget_lines l CROSS JOIN generate_series(date '2026-01-01', date '2026-09-01', interval '1 month') AS m WHERE NOT (l.code = 'S1' AND m = date '2026-09-01'); -- the September grant payment has not arrived -- A one-off overspend on materials in September. INSERT INTO actuals (line_id, booked_on, amount_eur, note) SELECT id, date '2026-09-20', 1400.00, 'one-off equipment' FROM budget_lines WHERE code = 'E3'; -
One query — the whole report
The query returns one JSON object.
plangives the plan for the month, year to date and the whole year;act— the actuals;linescalculates the signed variance (for revenue: actual − plan; for expenses: plan − actual, so that positive is always "better than plan");repadds percentages and theover_thresholdflag — if the month or the year to date is outside the threshold;totalsgives the sums, the result, the subsidy share and how much of the annual expense plan is spent;ctlcompares the sum of all entries for the year with the sum over the lines — if they differ, some amounts have no budget line of that year.sql · pack.sql-- Parameters: %(month)s = first day of the month being reported, %(threshold)s = variance in percent that gets a flag WITH p AS ( SELECT %(month)s::date AS m0, (%(month)s::date + interval '1 month')::date AS m1, date_trunc('year', %(month)s::date)::date AS y0 ), plan AS ( SELECT l.id, coalesce(sum(pl.planned_eur) FILTER (WHERE pl.month >= p.m0 AND pl.month < p.m1), 0) AS plan_month, coalesce(sum(pl.planned_eur) FILTER (WHERE pl.month >= p.y0 AND pl.month < p.m1), 0) AS plan_ytd, coalesce(sum(pl.planned_eur), 0) AS plan_year FROM budget_lines l CROSS JOIN p LEFT JOIN budget_plan pl ON pl.line_id = l.id WHERE l.year = extract(year FROM p.m0) GROUP BY l.id ), act AS ( SELECT a.line_id, coalesce(sum(a.amount_eur) FILTER (WHERE a.booked_on >= p.m0), 0) AS actual_month, sum(a.amount_eur) AS actual_ytd FROM actuals a CROSS JOIN p WHERE a.booked_on >= p.y0 AND a.booked_on < p.m1 GROUP BY a.line_id ), lines AS ( SELECT l.code, l.name, l.kind, plan.plan_month, plan.plan_ytd, plan.plan_year, coalesce(act.actual_month, 0) AS actual_month, coalesce(act.actual_ytd, 0) AS actual_ytd, -- positive = better than plan: more revenue, or less spent CASE WHEN l.kind = 'expense' THEN plan.plan_ytd - coalesce(act.actual_ytd, 0) ELSE coalesce(act.actual_ytd, 0) - plan.plan_ytd END AS variance_ytd, CASE WHEN l.kind = 'expense' THEN plan.plan_month - coalesce(act.actual_month, 0) ELSE coalesce(act.actual_month, 0) - plan.plan_month END AS variance_month FROM budget_lines l JOIN plan ON plan.id = l.id LEFT JOIN act ON act.line_id = l.id ), rep AS ( SELECT lines.*, round(100 * variance_ytd / nullif(plan_ytd, 0), 1) AS variance_pct, round(100 * variance_month / nullif(plan_month, 0), 1) AS variance_month_pct, -- flagged if the month OR the year to date is off by the threshold or more coalesce(abs(100 * variance_ytd / nullif(plan_ytd, 0)) >= %(threshold)s, false) OR coalesce(abs(100 * variance_month / nullif(plan_month, 0)) >= %(threshold)s, false) AS over_threshold, plan_year = 0 AND actual_ytd > 0 AS unplanned FROM lines ), tot AS ( SELECT coalesce(sum(plan_ytd) FILTER (WHERE kind = 'revenue_own'), 0) AS own_plan, coalesce(sum(actual_ytd) FILTER (WHERE kind = 'revenue_own'), 0) AS own_actual, coalesce(sum(plan_ytd) FILTER (WHERE kind = 'revenue_subsidy'), 0) AS sub_plan, coalesce(sum(actual_ytd) FILTER (WHERE kind = 'revenue_subsidy'), 0) AS sub_actual, coalesce(sum(plan_ytd) FILTER (WHERE kind = 'expense'), 0) AS exp_plan, coalesce(sum(actual_ytd) FILTER (WHERE kind = 'expense'), 0) AS exp_actual, coalesce(sum(plan_year) FILTER (WHERE kind = 'expense'), 0) AS exp_plan_year FROM rep ), totals AS ( SELECT own_plan, own_actual, sub_plan, sub_actual, exp_plan, exp_actual, own_plan + sub_plan - exp_plan AS result_plan, own_actual + sub_actual - exp_actual AS result_actual, round(100 * sub_actual / nullif(own_actual + sub_actual, 0), 1) AS subsidy_share_pct, round(100 * exp_actual / nullif(exp_plan_year, 0), 1) AS expense_used_of_year_pct FROM tot ), ctl AS ( -- every booked amount of the year must land on a line of that year, otherwise it is silently missing SELECT (SELECT coalesce(sum(amount_eur), 0) FROM actuals, p WHERE booked_on >= p.y0 AND booked_on < p.m1) AS booked_total, (SELECT coalesce(sum(actual_ytd), 0) FROM lines) AS lines_total ) SELECT json_build_object( 'month', %(month)s::date, 'currency', 'EUR', 'threshold_pct', %(threshold)s::numeric, 'lines', (SELECT coalesce(json_agg(row_to_json(rep) ORDER BY kind DESC, code), '[]'::json) FROM rep), 'totals', (SELECT row_to_json(totals) FROM totals), 'control', (SELECT row_to_json(ctl) FROM ctl) ) AS pack;For September 2026 on our sample the query returned (you get the same, because the data is deterministic):
Line Plan, year to date Actual, year to date Variance (EUR) Variance (%) Variance, month (%) Flag S1 · Subsidy 27,000.00 24,480.00 −2,520.00 −9.3 −100.0 yes R1 · Training fees 18,000.00 17,640.00 −360.00 −2.0 −10.0 no R2 · Consulting 13,500.00 13,230.00 −270.00 −2.0 −10.0 no E1 · Staff 37,800.00 36,540.00 +1,260.00 +3.3 −2.0 no E2 · Premises 10,800.00 10,440.00 +360.00 +3.3 −2.0 no E3 · Materials 5,400.00 6,620.00 −1,220.00 −22.6 −235.3 yes Total year to date: own revenue 31,500.00 plan / 30,870.00 actual; subsidy 27,000.00 / 24,480.00; expenses 54,000.00 / 53,600.00; result 4,500.00 plan / 1,750.00 actual; subsidy share of revenue 44.2 %; 74.4 % of the annual expense plan spent. Look at the subsidy line: for the year the variance is only −9.3 % (below the threshold), but for September it is −100 %. That is why the flag looks at the month too.
-
The script: comment, PDF, approval
Three things deserve attention. The control:
fetch_packstops if the booked amounts do not match the sum over the lines. What the model may do: its request tells it to use only the numbers, not to invent causes, not to state anything about rules and deadlines, and to ask the accountant questions;foreign_numbersshows numbers in the text that are not in the data. File and approval: Jinja2 runs withautoescape=True(the model's text cannot inject HTML); the checksum goes toreport_log, not inside the PDF;approvecalculates the checksum again and refuses if the file has changed. The database password comes fromBVA_DSN.python · budget_report.py"""Budget vs actual: SQL calculates, the model comments, a person approves.""" import argparse import hashlib import json import os import re from datetime import date from pathlib import Path import httpx import psycopg2 from jinja2 import Environment from weasyprint import HTML OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434") MODEL = os.environ["REPORT_MODEL"] # name of a model you have already pulled with ollama pull DSN = os.environ["BVA_DSN"] # from the environment, not in the code THRESHOLD = float(os.environ.get("VARIANCE_THRESHOLD", "15")) # % - a sample threshold; management decides OUT = Path("output") SCHEMA = { "type": "object", "properties": {"commentary": {"type": "string"}, "checks": {"type": "array", "items": {"type": "string"}}}, "required": ["commentary", "checks"], } KINDS = [("revenue_own", "Own revenue"), ("revenue_subsidy", "Subsidy"), ("expense", "Expenses")] def fetch_pack(conn, month: str) -> dict: with conn.cursor() as cur: cur.execute(Path("pack.sql").read_text(encoding="utf-8"), {"month": month, "threshold": THRESHOLD}) pack = cur.fetchone()[0] c = pack["control"] if abs(float(c["booked_total"]) - float(c["lines_total"])) > 0.005: raise ValueError("Some booked amounts do not land on a budget line of this year - no report is made.") return pack def numbers(s: str) -> set: return {float(n.replace(",", ".")) for n in re.findall(r"\d+(?:[.,]\d+)?", s)} def foreign_numbers(text: dict, pack: dict) -> list: blob = text["commentary"] + " " + " ".join(text["checks"]) return sorted(numbers(blob) - numbers(json.dumps(pack))) def narrative(pack: dict) -> dict: prompt = ( "You write a draft of a short commentary for a 'budget vs actual' report. " "Use ONLY the numbers in the data. Do not invent numbers or causes, and do not claim anything about rules, " "deadlines or obligations. Name up to 3 of the largest deviations (the lines with over_threshold = true) and " "what the accountant should check - as a question, not as an explanation. A positive variance means better " "than plan. Write in English, at most 150 words.\n\n" f"DATA (JSON):\n{json.dumps(pack, ensure_ascii=False)}\n\n" f"Return JSON that follows this schema: {json.dumps(SCHEMA)}" ) r = httpx.post(f"{OLLAMA_URL}/api/chat", timeout=300, json={ "model": MODEL, "messages": [{"role": "user", "content": prompt}], "format": SCHEMA, "stream": False, "options": {"temperature": 0.3, "num_predict": 800}}) r.raise_for_status() data = json.loads(r.json()["message"]["content"]) if any(k not in data for k in SCHEMA["required"]): raise ValueError("The model did not return all fields") return data TEMPLATE = """<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><style> @page { size: A4 landscape; margin: 14mm; } body { font-family: "DejaVu Sans", sans-serif; font-size: 8.5pt; color: #1c1c1c; } h1 { font-size: 14pt; color: #0d1b3e; margin: 0 0 2mm; } h2 { font-size: 10.5pt; color: #0b7ea8; border-bottom: 1px solid #b8cade; margin: 5mm 0 2mm; } table { width: 100%; border-collapse: collapse; } th { background: #0d1b3e; color: #fff; text-align: left; padding: 1.2mm 1.6mm; } td { padding: 1mm 1.6mm; border-bottom: 1px solid #e3e8ef; } th:first-child, td:first-child { width: 22%; } .r { text-align: right; } .draft { color: #b00020; font-weight: bold; } tr.flag td { background: #fff4e5; } .box { background: #f6f7fa; border-left: 3px solid #0abfb0; padding: 2.5mm; } .warn { background: #fff4e5; border-left: 3px solid #e8a020; padding: 2.5mm; margin-top: 3mm; } </style></head><body> <h1>Budget vs actual - {{ label }}</h1> <p class="draft">DRAFT - not approved - not financial or accounting advice</p> {% for kind, title in kinds %} <h2>{{ title }}</h2> <table><tr><th>Line</th><th class="r">Plan, month</th><th class="r">Actual, month</th> <th class="r">Plan, year to date</th><th class="r">Actual, year to date</th> <th class="r">Variance, year to date (EUR)</th><th class="r">Variance, year to date (%)</th><th class="r">Variance, month (%)</th></tr> {% for r in pack.lines if r.kind == kind %} <tr class="{{ 'flag' if r.over_threshold else '' }}"><td>{{ r.code }} · {{ r.name }}</td> <td class="r">{{ r.plan_month|eur }}</td><td class="r">{{ r.actual_month|eur }}</td> <td class="r">{{ r.plan_ytd|eur }}</td><td class="r">{{ r.actual_ytd|eur }}</td> <td class="r">{{ r.variance_ytd|eur(True) }}</td><td class="r">{{ r.variance_pct|pct }}</td><td class="r">{{ r.variance_month_pct|pct }}</td></tr> {% endfor %}</table> {% endfor %} <h2>Total, year to date</h2> <table><tr><th></th><th class="r">Plan</th><th class="r">Actual</th></tr> <tr><td>Own revenue</td><td class="r">{{ t.own_plan|eur }}</td><td class="r">{{ t.own_actual|eur }}</td></tr> <tr><td>Subsidy</td><td class="r">{{ t.sub_plan|eur }}</td><td class="r">{{ t.sub_actual|eur }}</td></tr> <tr><td>Expenses</td><td class="r">{{ t.exp_plan|eur }}</td><td class="r">{{ t.exp_actual|eur }}</td></tr> <tr><td><b>Result (revenue - expenses)</b></td><td class="r"><b>{{ t.result_plan|eur }}</b></td><td class="r"><b>{{ t.result_actual|eur }}</b></td></tr></table> <p>Subsidy share of revenue (actual): {{ t.subsidy_share_pct|pct }} · Share of the annual expense plan already spent: {{ t.expense_used_of_year_pct|pct }}. Shaded rows have a variance of at least {{ pack.threshold_pct|pct }} for the month or year to date - the threshold is a sample.</p> {% for r in pack.lines if r.unplanned %}<div class="warn">Line {{ r.code }} has no plan for the year but has booked amounts.</div>{% endfor %} <h2>Commentary (a draft from a local model)</h2> <div class="box">{{ text.commentary }}</div> <ul>{% for c in text.checks %}<li>{{ c }}</li>{% endfor %}</ul> {% if flags %}<div class="warn"><b>To check:</b> the text contains numbers that are not in the data: {{ flags }}</div>{% endif %} <p style="font-size:7.5pt;color:#666">Created {{ today }}. The checksum of the file is kept separately in the report log.</p> </body></html>""" def eur(v, signed=False) -> str: return f"{float(v):+,.2f}" if signed else f"{float(v):,.2f}" def pct(v) -> str: return "-" if v is None else f"{float(v):.1f} %" def render_pdf(pack: dict, text: dict, flags: list, month: str) -> Path: env = Environment(autoescape=True) # model text cannot inject HTML env.filters.update(eur=eur, pct=pct) html = env.from_string(TEMPLATE).render(pack=pack, t=pack["totals"], kinds=KINDS, text=text, flags=flags, label=month[:7], today=date.today().strftime("%d.%m.%Y")) folder = OUT / month[:7] folder.mkdir(parents=True, exist_ok=True) path = folder / f"bva_{month[:7]}.pdf" HTML(string=html).write_pdf(str(path)) return path def sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def cmd_make(conn, month: str) -> None: pack = fetch_pack(conn, month) text = narrative(pack) flags = foreign_numbers(text, pack) path = render_pdf(pack, text, flags, month) with conn.cursor() as cur: cur.execute("INSERT INTO report_log (period, pdf_path, sha256, status) VALUES (%s, %s, %s, 'draft')", (month, str(path), sha256(path))) conn.commit() print("Draft:", path, "· checksum:", sha256(path)[:16], "...") if flags: print("TO CHECK - numbers that are not in the data:", flags) def cmd_approve(conn, month: str, by: str) -> None: with conn.cursor() as cur: cur.execute("SELECT id, pdf_path, sha256 FROM report_log WHERE period = %s AND status = 'draft' " "ORDER BY id DESC LIMIT 1", (month,)) row = cur.fetchone() if not row: raise SystemExit("There is no draft for this period.") log_id, path, digest = row if sha256(Path(path)) != digest: raise SystemExit("The file was changed after it was created - make the report again.") cur.execute("UPDATE report_log SET status = 'approved', approved_at = now(), approved_by = %s WHERE id = %s", (by, log_id)) conn.commit() print("Approved:", path, "· by:", by, "· sending stays manual.") if __name__ == "__main__": ap = argparse.ArgumentParser() sub = ap.add_subparsers(dest="cmd", required=True) m = sub.add_parser("make"); m.add_argument("--month", required=True, help="first day, e.g. 2026-09-01") p = sub.add_parser("approve"); p.add_argument("--month", required=True); p.add_argument("--by", required=True) a = ap.parse_args() with psycopg2.connect(DSN) as conn: cmd_make(conn, a.month) if a.cmd == "make" else cmd_approve(conn, a.month, a.by) -
Run it
bashexport BVA_DSN='postgresql://<user>:<password>@localhost/<database>' export REPORT_MODEL='<model>' # export VARIANCE_THRESHOLD=15 # optional: threshold in percent psql "$BVA_DSN" -f schema.sql psql "$BVA_DSN" -f seed.sql # invented data python budget_report.py make --month 2026-09-01 # read output/2026-09/bva_2026-09.pdf, then: python budget_report.py approve --month 2026-09-01 --by "<name>"The PDF has tables by kind of line (a shaded background for lines over the threshold), totals, a warning for lines without a plan, the model's comment and — if there are any — a "To check" box with foreign numbers. If you change the file and run
approve, the command refuses. -
How to read the report, and the traps
⚠️The model does not know whyA late subsidy is a question ("was it received?"), not an explanation. That is why the request forbids causes and statements about rules. If the comment contains them anyway, strike them out — causes are established by people.💡The plan must follow realityIf the subsidy comes in quarterly instalments, record the plan in the months of the instalments — otherwise every quarter will show up as a "variance". The same goes for seasonal revenue and annual costs.🔒DataOnly summarised numbers by line go to the model — no agreements, no names of people or counterparties. Local processing helps with confidentiality, but it does not replace the rules set by whoever grants you the subsidy.
04Check
- The tables are created, the data is loaded, and
pack.sqlreturns JSON withlines,totalsandcontrol. - The subsidy and materials lines have a flag; the others do not.
- An amount booked on a line of another year stops the report.
- The PDF is created, the Cyrillic is readable, and text such as "<b>" from the model appears as text.
foreign_numberscatches a number that is not in the data.approverefuses if the file was changed after it was created.- A person reads the draft and sends it themselves; the accountant confirms every figure.
Quiz
1. Why is the year-to-date plan a sum of monthly plans and not "annual ÷ 12 × months"?
2. What does a positive variance on an "expense" line mean?
3. Why does the flag check both the month and the year to date?
4. What is the control "sum of entries = sum over lines" for?
05What's next
06Sources
- Ollama: API /api/chat · structured outputs 🔒 local — the
formatfield with a schema; the advice to put the schema in the prompt too (read 03.10.2026). - WeasyPrint: first steps and installation · PyPI — version 70.0 (03.10.2026).
- Jinja2: API (autoescaping) · Python: hashlib.
- PostgreSQL: aggregate functions (FILTER) · JSON functions · WITH queries.