The KAGAMI mark КАГАМИ
kagami.bg/academy · lesson · machine-readable viewUPDATED 2026-10-03
IDENTITY
module
GX10-04-189 · Budget vs actual: a report with SQL and a local AI
series
GX10 (local AI server class: NVIDIA GB10, e.g. ASUS Ascent GX10 / DGX Spark)
level
Intermediate
duration
about 2 h
prerequisites
PostgreSQL, Ollama with a chat model already pulled, Python 3.10+, WeasyPrint system libraries (Pango) and a font with Cyrillic such as DejaVu Sans, basic SQL
trust_label
TESTED 2026-10-03 on a GB10-class machine (GX10) · Python 3.12.3, Docker 29.2.1 (PostgreSQL), WeasyPrint 68.1, Jinja2 3.1.2 · model gemma3:4b · PDF in ~35 s (18,254 bytes), peak 69 °C · quality of the Bulgarian comment, other fonts and scheduling not checked
scope_limit
Not financial or accounting advice. The lesson makes no statements about reporting duties, deadlines or accounting treatment of subsidies; those are for the accountant and the body that grants the subsidy. The threshold of 15 percent is a sample. Invented data (an invented company, Primerna Ltd.)
language
human view: bg · english edition: /en/academy/gx10/ (same file name)
previous / next
04-188_Incident_Pattern_Analysis.html / 04-190_Procurement_Compliance.html
PURPOSE

Build a monthly budget-vs-actual pack for a small organisation with own revenue, a subsidy and expenses. One SQL query returns JSON: per budget line the plan for the month and year to date, the actuals, a signed variance (positive means better than plan) in euros and percent, a flag when the month or the year to date is off by a threshold, lines without any plan; totals by revenue type and expenses, the result, the subsidy share of revenue and the share of the annual expense plan already used; and a control that every booked amount lands on a line of that year. A local model drafts a short comment from those numbers only, by a JSON schema; code flags numbers not present in the data; Jinja2 (autoescape on) and WeasyPrint render a PDF marked as a draft; its SHA-256 goes to a log table; a person approves with a separate command that refuses if the file changed. Nothing is sent automatically.

KEY CONCEPTS
COMMANDS / PATHS
CHECKLIST
NEXT MODULE

04-190 · A public procurement check with a local AI (04-190_Procurement_Compliance.html) · related: 04-178 monthly accounting report, 04-186 financial model with scenarios, 04-166 revenue analysis · series index: kagami.bg/academy/gx10/ · offer: Quick experiment (kagami.bg/stalbata/)

SOURCES
TAGS
gx10nvidia-gb10budgetvariance-analysispostgresqlollamaweasyprintpdfhuman-in-the-loop
TESTED · 3 Oct 2026 UPDATED · 03.10.2026

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."

⏱ about 2 h Intermediate GX10 PostgreSQL · Ollama · Python WeasyPrint · Jinja2
PostgreSQL · Python (httpx, psycopg2, Jinja2, WeasyPrint)🔒 local Ollama (the model)🔒 local
🔄
UPDATED · 03.10.2026 — what was updated
The lesson was written anew as a general example for a small organisation with a subsidy. A few decisions make the report more honest: the plan is stored by month (the year-to-date plan is a sum of months, not "annual ÷ 12"); the variance is signed (positive = better than plan); the flag looks at both the month and the year to date; there is a control that every booked amount lands on a budget line; lines without a plan are shown, not hidden. The model writes only a comment and may not explain causes. Checked as of 03.10.2026: WeasyPrint 70.0 (PyPI), Ollama structured outputs.
🧪
What we ran on a GB10
3 Oct 2026 · run on a GB10 (GX10) by Claudette · Python 3.12.3 · Docker 29.2.1 (PostgreSQL in a container) · WeasyPrint 68.1 · Jinja2 3.1.2. Model actually used: gemma3:4b. The schema, data, 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).
⚖️
Not financial or accounting advice
The lesson shows how to arrange a report. It makes no statements about which reports you must produce, in what form and by what deadlines, or about how subsidies are treated in accounts — ask your accountant and the body that grants the subsidy. The 15 % threshold is a sample. The accountant confirms every figure.

01What you'll learn

02Before you start

03Steps

  1. What we will build

    WhatWho does itWhy
    Plan, actual, variance, subsidy shareOne query to PostgreSQLNumbers must not depend on the mood of a model
    A control on the entriesCodeAn amount that lands nowhere must not vanish silently
    A short commentA local model — a draftIt writes fast, but it can be wrong and invent causes
    PDF and checksumCodeThe file can be checked later
    Approval and sendingA personThe responsibility for the report is theirs
  2. Tables: a budget line, a monthly plan, actuals

    budget_lines says what a line is: revenue_own (own revenue), revenue_subsidy (subsidy) or expense. budget_plan keeps the plan by month — so a subsidy that comes in instalments is planned in the months when it is expected, not "evenly". actuals are the booked amounts — always positive; the kind of the line decides the sign. report_log is the log of reports. All amounts are in euros.

    sql · schema.sql
    CREATE 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
    );
  3. 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';
  4. One query — the whole report

    The query returns one JSON object. plan gives the plan for the month, year to date and the whole year; act — the actuals; lines calculates the signed variance (for revenue: actual − plan; for expenses: plan − actual, so that positive is always "better than plan"); rep adds percentages and the over_threshold flag — if the month or the year to date is outside the threshold; totals gives the sums, the result, the subsidy share and how much of the annual expense plan is spent; ctl compares 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):

    LinePlan, year to dateActual, year to dateVariance (EUR)Variance (%)Variance, month (%)Flag
    S1 · Subsidy27,000.0024,480.00−2,520.00−9.3−100.0yes
    R1 · Training fees18,000.0017,640.00−360.00−2.0−10.0no
    R2 · Consulting13,500.0013,230.00−270.00−2.0−10.0no
    E1 · Staff37,800.0036,540.00+1,260.00+3.3−2.0no
    E2 · Premises10,800.0010,440.00+360.00+3.3−2.0no
    E3 · Materials5,400.006,620.00−1,220.00−22.6−235.3yes

    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.

  5. The script: comment, PDF, approval

    Three things deserve attention. The control: fetch_pack stops 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_numbers shows numbers in the text that are not in the data. File and approval: Jinja2 runs with autoescape=True (the model's text cannot inject HTML); the checksum goes to report_log, not inside the PDF; approve calculates the checksum again and refuses if the file has changed. The database password comes from BVA_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)
  6. Run it

    bash
    export 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.

  7. How to read the report, and the traps

    ⚠️
    The model does not know why
    A 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 reality
    If 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.
    🔒
    Data
    Only 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

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

  1. Ollama: API /api/chat · structured outputs 🔒 local — the format field with a schema; the advice to put the schema in the prompt too (read 03.10.2026).
  2. WeasyPrint: first steps and installation · PyPI — version 70.0 (03.10.2026).
  3. Jinja2: API (autoescaping) · Python: hashlib.
  4. PostgreSQL: aggregate functions (FILTER) · JSON functions · WITH queries.