profplate's picture
Update app.py
5359611 verified
"""
Budget Advisor β€” a personal financial-planning tool.
Visual direction: editorial private-wealth report. Warm off-white paper,
serif headlines, monospace figures, no emoji. All styling is delivered
through a custom Gradio CSS override plus Google Fonts (Source Serif 4,
Inter, JetBrains Mono). The Gradio theme is kept intentionally neutral
so the custom CSS fully controls the look.
"""
import gradio as gr
import json
import tempfile
from datetime import datetime
# ── constants ──────────────────────────────────────────────────────────────
EXPENSE_CATS = [
"Housing", "Food & groceries", "Transport", "Utilities",
"Entertainment", "Health", "Education", "Clothing", "Other",
]
CAT_LIMITS = {
"Housing": 30, "Food & groceries": 15, "Transport": 15,
"Utilities": 10, "Entertainment": 10, "Health": 10,
"Education": 10, "Clothing": 5, "Other": 10,
}
# Neutral palette of swatches for the stacked-bar + swatch column.
CAT_SWATCH = {
"Housing": "#1c1a17",
"Food & groceries": "#3d3932",
"Transport": "#5e5849",
"Utilities": "#7a7462",
"Entertainment": "#958f7e",
"Health": "#a8a28f",
"Education": "#b6b09d",
"Clothing": "#c4bead",
"Other": "#d3cdbb",
}
FOLLOW_UP_PROMPT = """You are a practical, direct personal finance advisor. I am going to give you a JSON object that summarizes my income, pre-tax deductions, retirement contribution, and monthly expenses. Please do the following:
1. Open with a two- to three-sentence read on the overall health of this budget β€” what is working, what is concerning.
2. Provide five to six specific, numbered recommendations. Reference the actual dollar amounts and percentages from the JSON. Each should be one or two sentences.
3. Flag any category where spending exceeds the recommended ceiling (the `limit_pct` field), and suggest a realistic target dollar amount.
4. Close with a single highest-priority action for the coming month.
Be direct and honest, but constructive. Translate the numbers into decisions.
Here is the budget:
"""
# ── formatting helpers ─────────────────────────────────────────────────────
def fmt(n: float) -> str:
sign = "βˆ’" if n < 0 else ""
return f"{sign}${abs(round(n)):,}"
def pct(part: float, whole: float, decimals: int = 1) -> str:
if whole == 0:
return "0.0%"
return f"{round(part / whole * 100, decimals)}%"
# ── HTML builders (return strings used by gr.HTML blocks) ──────────────────
def _kpi_row(gross, takehome, remaining):
rem_cls = "accent" if remaining >= 0 else "warn"
return f"""
<div class="ba-kpi-row">
<div class="ba-kpi">
<div class="ba-kpi-label">Gross</div>
<div class="ba-kpi-amount">{fmt(gross)}</div>
<div class="ba-kpi-sub">100.0%</div>
</div>
<div class="ba-kpi accent">
<div class="ba-kpi-label">Take-home</div>
<div class="ba-kpi-amount">{fmt(takehome)}</div>
<div class="ba-kpi-sub">{pct(takehome, gross)} of gross</div>
</div>
<div class="ba-kpi {rem_cls}">
<div class="ba-kpi-label">Remaining</div>
<div class="ba-kpi-amount">{fmt(remaining)}</div>
<div class="ba-kpi-sub">{pct(remaining, takehome)} of take-home</div>
</div>
</div>
"""
def _flow_table(gross, fed, state, fica, mcare, k401, takehome):
return f"""
<div class="ba-section">
<div class="ba-section-head">
<h3>Income flow</h3>
<span class="ba-hint">Withholdings &amp; savings</span>
</div>
<table class="ba-ledger">
<thead>
<tr><th>Line item</th><th>Amount</th><th>% gross</th></tr>
</thead>
<tbody>
<tr><td>Gross income</td><td>{fmt(gross)}</td><td>100.0%</td></tr>
<tr><td>Federal tax</td><td>βˆ’{fmt(fed)}</td><td>{pct(fed, gross)}</td></tr>
<tr><td>State tax</td><td>βˆ’{fmt(state)}</td><td>{pct(state, gross)}</td></tr>
<tr><td>Social Security</td><td>βˆ’{fmt(fica)}</td><td>{pct(fica, gross)}</td></tr>
<tr><td>Medicare</td><td>βˆ’{fmt(mcare)}</td><td>{pct(mcare, gross)}</td></tr>
<tr><td>401(k) contribution</td><td>βˆ’{fmt(k401)}</td><td>{pct(k401, gross)}</td></tr>
<tr class="ba-total"><td>Take-home pay</td><td>{fmt(takehome)}</td><td>{pct(takehome, gross)}</td></tr>
</tbody>
</table>
</div>
"""
def _spending_block(used, takehome):
# stacked bar
segs = []
rows = []
for cat, amt in used.items():
color = CAT_SWATCH.get(cat, "#8a8478")
share = (amt / takehome * 100) if takehome > 0 else 0
limit = CAT_LIMITS.get(cat, 10)
if share > limit:
pill = f'<span class="ba-pill over">Over {limit}%</span>'
elif share > limit * 0.8:
pill = f'<span class="ba-pill near">Near {limit}%</span>'
else:
pill = ""
# bar segments scale against take-home so empty space is remaining
segs.append(
f'<div class="ba-seg" style="width:{max(share,0):.2f}%;background:{color}"></div>'
)
rows.append(f"""
<div class="ba-cat-row">
<div class="ba-swatch" style="background:{color}"></div>
<div class="ba-cat-name">{cat}</div>
<div class="ba-cat-amt">{fmt(amt)}</div>
<div class="ba-cat-pct">{share:.1f}%{pill}</div>
</div>
""")
return f"""
<div class="ba-section">
<div class="ba-section-head">
<h3>Spending allocation</h3>
<span class="ba-hint">vs. take-home</span>
</div>
<div class="ba-bar">{''.join(segs)}</div>
<div class="ba-cat-list">{''.join(rows)}</div>
</div>
"""
def _position_block(total_exp, remaining, takehome, k401, gross):
savings_rate = ((k401 + max(remaining, 0)) / gross * 100) if gross > 0 else 0
return f"""
<div class="ba-section" style="margin-bottom:0">
<div class="ba-section-head">
<h3>Position</h3>
<span class="ba-hint">end of month</span>
</div>
<table class="ba-ledger">
<tbody>
<tr><td>Total expenses</td><td>{fmt(total_exp)}</td><td>{pct(total_exp, takehome)}</td></tr>
<tr><td>Discretionary surplus</td><td>{fmt(max(remaining, 0))}</td><td>{pct(max(remaining, 0), takehome)}</td></tr>
<tr class="ba-total"><td>Effective savings rate</td><td>{fmt(k401 + max(remaining, 0))}</td><td>{savings_rate:.1f}%</td></tr>
</tbody>
</table>
</div>
"""
def _empty_report(message="Enter your income to generate the report."):
return f"""
<div class="ba-report-empty">
<div class="ba-kicker">Advisory report</div>
<h2 class="ba-report-title">Awaiting inputs</h2>
<p class="ba-report-sub"><em>{message}</em></p>
</div>
"""
def _report_header():
now = datetime.now().strftime("%b %Y")
return f"""
<div class="ba-report-head">
<span class="ba-kicker">Advisory report</span>
<span class="ba-meta">{now}</span>
</div>
<h2 class="ba-report-title">Monthly Position</h2>
<p class="ba-report-sub"><em>Prepared from your declared inputs. Figures rounded to the nearest dollar.</em></p>
"""
# ── core calculation ───────────────────────────────────────────────────────
def calculate_budget(
gross_income,
fed_tax_pct, state_tax_pct, fica_pct, medicare_pct, k401_pct,
housing, food, transport, utilities,
entertainment, health, education, clothing, other,
):
if gross_income is None or gross_income <= 0:
empty_json = "{}"
return (
_empty_report("Please enter a valid monthly gross income to generate the report."),
empty_json, FOLLOW_UP_PROMPT + empty_json, None,
)
fed_amt = gross_income * fed_tax_pct / 100
state_amt = gross_income * state_tax_pct / 100
fica_amt = gross_income * fica_pct / 100
medicare_amt = gross_income * medicare_pct / 100
k401_amt = gross_income * k401_pct / 100
total_tax = fed_amt + state_amt + fica_amt + medicare_amt
total_ded = total_tax + k401_amt
takehome = gross_income - total_ded
expenses = {
"Housing": housing,
"Food & groceries": food,
"Transport": transport,
"Utilities": utilities,
"Entertainment": entertainment,
"Health": health,
"Education": education,
"Clothing": clothing,
"Other": other,
}
used = {k: (v or 0) for k, v in expenses.items() if v and v > 0}
total_exp = sum(used.values())
remaining = takehome - total_exp
# ── report html ──
report_html = (
_report_header()
+ _kpi_row(gross_income, takehome, remaining)
+ _flow_table(gross_income, fed_amt, state_amt, fica_amt, medicare_amt, k401_amt, takehome)
+ _spending_block(used, takehome)
+ _position_block(total_exp, remaining, takehome, k401_amt, gross_income)
)
# ── structured JSON export ──
def _round(x):
return round(float(x), 2)
expense_breakdown = []
for cat, amt in used.items():
limit = CAT_LIMITS.get(cat, 10)
cat_pct = round(amt / takehome * 100, 2) if takehome > 0 else 0
if cat_pct <= limit * 0.8:
status = "ok"
elif cat_pct <= limit:
status = "near_limit"
else:
status = "over_limit"
expense_breakdown.append({
"category": cat,
"amount_usd": _round(amt),
"pct_of_takehome": cat_pct,
"limit_pct": limit,
"status": status,
})
export_data = {
"schema": "budget-ai-advisor/v1",
"generated_at": datetime.utcnow().isoformat() + "Z",
"currency": "USD",
"period": "monthly",
"income": {"gross_usd": _round(gross_income)},
"pre_tax_deductions": {
"federal_tax_pct": _round(fed_tax_pct),
"state_tax_pct": _round(state_tax_pct),
"social_security_pct": _round(fica_pct),
"medicare_pct": _round(medicare_pct),
"retirement_401k_pct": _round(k401_pct),
"federal_tax_usd": _round(fed_amt),
"state_tax_usd": _round(state_amt),
"social_security_usd": _round(fica_amt),
"medicare_usd": _round(medicare_amt),
"retirement_401k_usd": _round(k401_amt),
"total_taxes_usd": _round(total_tax),
},
"take_home_usd": _round(takehome),
"expenses": expense_breakdown,
"totals": {
"total_expenses_usd": _round(total_exp),
"remaining_usd": _round(remaining),
"remaining_pct_of_takehome":
round(remaining / takehome * 100, 2) if takehome > 0 else 0,
"savings_rate_pct_of_gross":
round((k401_amt + max(remaining, 0)) / gross_income * 100, 2)
if gross_income > 0 else 0,
},
"notes": {
"limits_reference":
"Each expense's `limit_pct` is a recommended ceiling as a share of "
"take-home pay. `status` is 'ok' if under 80% of the limit, "
"'near_limit' if 80-100%, 'over_limit' if above.",
"savings_rate_definition":
"savings_rate_pct_of_gross = (401(k) contribution + leftover after expenses) / gross income",
},
}
export_json = json.dumps(export_data, indent=2)
full_prompt = FOLLOW_UP_PROMPT + export_json
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
tmp_dir = tempfile.gettempdir()
download_path = f"{tmp_dir}/budget-export-{timestamp}.json"
with open(download_path, "w") as f:
f.write(export_json)
return report_html, export_json, full_prompt, download_path
# ── Custom CSS ─────────────────────────────────────────────────────────────
APP_CSS = """
@import url('https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,300..700;1,8..60,400&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
:root, .gradio-container {
--ba-bg: #faf7f2;
--ba-bg-sunk: #f1ecdf;
--ba-card: #fffdf8;
--ba-ink: #1c1a17;
--ba-ink-2: #4a463f;
--ba-ink-3: #8a8478;
--ba-rule: #e5dfd2;
--ba-accent: oklch(0.42 0.05 155);
--ba-warn: oklch(0.58 0.12 55);
--ba-danger: oklch(0.50 0.15 30);
--ba-serif: "Source Serif 4", "Iowan Old Style", Georgia, serif;
--ba-sans: "Inter", -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
--ba-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* ── Page shell ───────────────────────────────────────────────── */
html, body, .gradio-container {
background: var(--ba-bg) !important;
color: var(--ba-ink) !important;
font-family: var(--ba-sans) !important;
}
.gradio-container {
max-width: 1240px !important;
margin: 0 auto !important;
padding: 40px 48px 80px !important;
}
/* ── Masthead + hero (rendered via gr.HTML) ──────────────────── */
.ba-masthead {
border-bottom: 1px solid var(--ba-ink);
padding-bottom: 18px;
margin-bottom: 28px;
display: flex;
justify-content: space-between;
align-items: baseline;
}
.ba-brand {
font-family: var(--ba-serif);
font-weight: 400;
font-style: italic;
font-size: 22px;
letter-spacing: -0.01em;
}
.ba-brand::before {
content: "Β§";
margin-right: 8px;
color: var(--ba-accent);
font-style: normal;
}
.ba-brand-title {
font-family: var(--ba-serif);
font-style: normal;
font-weight: 400;
font-size: 15px;
color: var(--ba-ink-2);
margin-left: 12px;
letter-spacing: 0.02em;
}
.ba-meta {
font-family: var(--ba-mono);
font-size: 11px;
color: var(--ba-ink-3);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ba-hero { margin-bottom: 36px; max-width: 760px; }
.ba-hero h1 {
font-family: var(--ba-serif);
font-weight: 400;
font-size: 42px;
line-height: 1.1;
letter-spacing: -0.015em;
margin: 0 0 14px;
}
.ba-hero h1 em { font-style: italic; color: var(--ba-accent); }
.ba-hero p {
font-family: var(--ba-serif);
font-size: 16px;
line-height: 1.55;
color: var(--ba-ink-2);
margin: 0;
max-width: 640px;
}
/* ── Gradio row / column as a two-column grid feels natural ───── */
.gradio-container .prose { font-family: var(--ba-sans) !important; color: var(--ba-ink) !important; }
/* Section headers we render as markdown */
.ba-panel-head {
display: flex;
justify-content: space-between;
align-items: baseline;
border-bottom: 1px solid var(--ba-ink);
padding-bottom: 8px;
margin: 24px 0 14px;
}
.ba-panel-head h2 {
font-family: var(--ba-serif) !important;
font-weight: 500 !important;
font-size: 20px !important;
margin: 0 !important;
letter-spacing: -0.005em;
color: var(--ba-ink) !important;
}
.ba-panel-head .ba-idx {
font-family: var(--ba-mono);
font-size: 10px;
letter-spacing: 0.14em;
color: var(--ba-ink-3);
text-transform: uppercase;
}
.ba-panel-note {
font-family: var(--ba-serif);
font-style: italic;
font-size: 14px;
color: var(--ba-ink-3);
margin: -4px 0 14px;
}
/* ── Gradio input controls ──────────────────────────────────── */
/* Labels */
.gradio-container label span,
.gradio-container .label-wrap > span,
.gradio-container .block > .label > span,
.gradio-container span[data-testid="block-info"] {
font-family: var(--ba-sans) !important;
font-size: 13px !important;
font-weight: 400 !important;
color: var(--ba-ink-2) !important;
letter-spacing: 0 !important;
}
/* Number inputs */
.gradio-container input[type="number"],
.gradio-container input[type="text"] {
font-family: var(--ba-mono) !important;
font-size: 14px !important;
text-align: right !important;
background: var(--ba-card) !important;
border: 1px solid var(--ba-rule) !important;
color: var(--ba-ink) !important;
border-radius: 2px !important;
padding: 9px 12px !important;
box-shadow: none !important;
transition: border-color .15s;
}
.gradio-container input[type="number"]:focus,
.gradio-container input[type="text"]:focus {
border-color: var(--ba-ink) !important;
outline: none !important;
}
/* Sliders */
.gradio-container input[type="range"] {
accent-color: var(--ba-ink) !important;
height: 2px !important;
}
.gradio-container .wrap.svelte-1cl284s,
.gradio-container .wrap .head,
.gradio-container .slider_input_container .tick-marks,
.gradio-container [data-testid="slider"] {
background: transparent !important;
}
/* Slider value readout β€” force mono */
.gradio-container .slider_input_container input,
.gradio-container [data-testid="slider"] input {
font-family: var(--ba-mono) !important;
font-size: 12px !important;
background: transparent !important;
border: none !important;
color: var(--ba-ink) !important;
text-align: right !important;
padding: 0 !important;
width: 56px !important;
}
/* Remove Gradio's default card backgrounds on form blocks */
.gradio-container .block,
.gradio-container .form,
.gradio-container .gap,
.gradio-container fieldset {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* Give number/slider blocks a bottom rule to feel like a ledger */
.gradio-container .block.svelte-11xb1hd,
.gradio-container [data-testid="number-input"],
.gradio-container [data-testid="slider"] {
border-bottom: 1px solid var(--ba-rule) !important;
padding: 12px 0 !important;
border-radius: 0 !important;
}
/* Button: turn Gradio primary into a serious black pill */
.gradio-container button.primary,
.gradio-container button[variant="primary"],
.gradio-container .primary button {
background: var(--ba-ink) !important;
color: var(--ba-bg) !important;
border: 1px solid var(--ba-ink) !important;
border-radius: 2px !important;
font-family: var(--ba-sans) !important;
font-weight: 500 !important;
font-size: 13px !important;
letter-spacing: 0.01em !important;
padding: 11px 22px !important;
box-shadow: none !important;
transition: background .15s, border-color .15s;
}
.gradio-container button.primary:hover,
.gradio-container button[variant="primary"]:hover {
background: var(--ba-accent) !important;
border-color: var(--ba-accent) !important;
}
.gradio-container button.secondary,
.gradio-container button[variant="secondary"] {
background: transparent !important;
color: var(--ba-ink) !important;
border: 1px solid var(--ba-ink) !important;
border-radius: 2px !important;
font-family: var(--ba-sans) !important;
font-weight: 500 !important;
font-size: 13px !important;
padding: 11px 22px !important;
box-shadow: none !important;
}
.gradio-container button.secondary:hover {
background: var(--ba-bg-sunk) !important;
}
/* ── Results sheet (gr.HTML output) ─────────────────────────── */
.ba-results-sheet {
background: var(--ba-card);
border: 1px solid var(--ba-rule);
padding: 32px 36px;
}
.ba-report-head {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.ba-kicker {
font-family: var(--ba-mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ba-ink-3);
}
.ba-report-title {
font-family: var(--ba-serif);
font-size: 26px;
font-weight: 400;
letter-spacing: -0.01em;
margin: 2px 0 4px;
color: var(--ba-ink);
}
.ba-report-sub {
font-family: var(--ba-serif);
font-size: 13px;
color: var(--ba-ink-3);
margin: 0 0 22px;
padding-bottom: 18px;
border-bottom: 1px solid var(--ba-rule);
}
.ba-report-empty {
padding: 12px 0 8px;
}
.ba-report-empty .ba-report-title { margin-top: 4px; }
.ba-kpi-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
border: 1px solid var(--ba-rule);
margin-bottom: 26px;
}
.ba-kpi {
padding: 16px 16px;
border-right: 1px solid var(--ba-rule);
}
.ba-kpi:last-child { border-right: none; }
.ba-kpi-label {
font-family: var(--ba-mono);
font-size: 10px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--ba-ink-3);
margin-bottom: 6px;
}
.ba-kpi-amount {
font-family: var(--ba-serif);
font-size: 24px;
font-weight: 400;
letter-spacing: -0.01em;
line-height: 1;
color: var(--ba-ink);
}
.ba-kpi-sub {
font-family: var(--ba-mono);
font-size: 11px;
color: var(--ba-ink-3);
margin-top: 6px;
}
.ba-kpi.accent .ba-kpi-amount { color: var(--ba-accent); }
.ba-kpi.warn .ba-kpi-amount { color: var(--ba-danger); }
.ba-section { margin-bottom: 26px; }
.ba-section-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 10px;
}
.ba-section-head h3 {
font-family: var(--ba-serif);
font-weight: 500;
font-size: 15px;
margin: 0;
color: var(--ba-ink);
}
.ba-hint {
font-family: var(--ba-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ba-ink-3);
}
.ba-ledger {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.ba-ledger th {
font-family: var(--ba-mono);
font-weight: 500;
font-size: 10px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--ba-ink-3);
text-align: right;
padding: 6px 0;
border-bottom: 1px solid var(--ba-rule);
}
.ba-ledger th:first-child { text-align: left; }
.ba-ledger td {
padding: 9px 0;
border-bottom: 1px dotted var(--ba-rule);
font-family: var(--ba-mono);
font-size: 12.5px;
text-align: right;
color: var(--ba-ink);
}
.ba-ledger td:first-child {
text-align: left;
font-family: var(--ba-sans);
font-size: 13px;
color: var(--ba-ink-2);
}
.ba-ledger tr.ba-total td {
border-top: 1px solid var(--ba-ink);
border-bottom: none;
padding-top: 12px;
font-weight: 600;
color: var(--ba-ink);
}
.ba-ledger tr.ba-total td:first-child {
font-family: var(--ba-serif);
font-weight: 500;
font-size: 14px;
}
.ba-bar {
display: flex;
height: 10px;
width: 100%;
background: var(--ba-bg-sunk);
margin-bottom: 14px;
overflow: hidden;
}
.ba-seg { height: 100%; border-right: 1px solid var(--ba-card); }
.ba-seg:last-child { border-right: none; }
.ba-cat-row {
display: grid;
grid-template-columns: 14px 1fr auto 130px;
gap: 12px;
align-items: center;
padding: 8px 0;
border-bottom: 1px dotted var(--ba-rule);
font-size: 13px;
}
.ba-cat-row:last-child { border-bottom: none; }
.ba-swatch { width: 10px; height: 10px; }
.ba-cat-name { color: var(--ba-ink-2); }
.ba-cat-amt {
font-family: var(--ba-mono);
font-size: 12.5px;
color: var(--ba-ink);
}
.ba-cat-pct {
font-family: var(--ba-mono);
font-size: 11px;
color: var(--ba-ink-3);
text-align: right;
}
.ba-pill {
display: inline-block;
font-family: var(--ba-mono);
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 2px 6px;
border: 1px solid;
margin-left: 6px;
vertical-align: 2px;
}
.ba-pill.over { color: var(--ba-danger); border-color: var(--ba-danger); }
.ba-pill.near { color: var(--ba-warn); border-color: var(--ba-warn); }
/* ── Handoff section ───────────────────────────────────────── */
.ba-handoff-head {
border-top: 1px solid var(--ba-ink);
padding-top: 28px;
margin-top: 40px;
}
.ba-handoff-head h2 {
font-family: var(--ba-serif);
font-weight: 400;
font-size: 26px;
letter-spacing: -0.01em;
margin: 0 0 10px;
}
.ba-handoff-head p {
font-family: var(--ba-serif);
font-size: 15px;
color: var(--ba-ink-2);
max-width: 680px;
margin: 0 0 22px;
}
.ba-steps {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 22px;
margin-bottom: 20px;
}
.ba-step { border-top: 1px solid var(--ba-rule); padding-top: 12px; }
.ba-step .ba-n {
font-family: var(--ba-mono);
font-size: 10px;
letter-spacing: 0.14em;
color: var(--ba-ink-3);
text-transform: uppercase;
margin-bottom: 6px;
}
.ba-step h4 {
font-family: var(--ba-serif);
font-weight: 500;
font-size: 15px;
margin: 0 0 4px;
color: var(--ba-ink);
}
.ba-step p {
font-family: var(--ba-sans);
font-size: 13px;
color: var(--ba-ink-2);
margin: 0;
}
/* Gradio Code block override */
.gradio-container .cm-editor,
.gradio-container .cm-content,
.gradio-container pre,
.gradio-container code {
font-family: var(--ba-mono) !important;
font-size: 12px !important;
line-height: 1.55 !important;
}
.gradio-container .cm-editor {
background: #1c1a17 !important;
color: #e8e3d9 !important;
border: 1px solid var(--ba-rule) !important;
border-radius: 2px !important;
}
/* Accordion styling */
.gradio-container .label-wrap,
.gradio-container details > summary {
font-family: var(--ba-serif) !important;
font-size: 15px !important;
font-weight: 500 !important;
color: var(--ba-ink) !important;
padding: 10px 0 !important;
border-bottom: 1px solid var(--ba-rule) !important;
}
/* File download component */
.gradio-container [data-testid="file"] {
background: var(--ba-card) !important;
border: 1px solid var(--ba-rule) !important;
border-radius: 2px !important;
}
/* Footer text */
.ba-footer {
margin-top: 56px;
padding-top: 20px;
border-top: 1px solid var(--ba-rule);
display: flex;
justify-content: space-between;
font-family: var(--ba-mono);
font-size: 10px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--ba-ink-3);
}
"""
APP_THEME = gr.themes.Base(
primary_hue=gr.themes.colors.stone,
secondary_hue=gr.themes.colors.stone,
neutral_hue=gr.themes.colors.stone,
font=("Inter", "system-ui", "sans-serif"),
font_mono=("JetBrains Mono", "ui-monospace", "monospace"),
).set(
body_background_fill="#faf7f2",
body_text_color="#1c1a17",
background_fill_primary="#faf7f2",
background_fill_secondary="#faf7f2",
border_color_primary="#e5dfd2",
block_background_fill="transparent",
block_border_width="0px",
block_label_background_fill="transparent",
button_primary_background_fill="#1c1a17",
button_primary_text_color="#faf7f2",
button_secondary_background_fill="transparent",
button_secondary_text_color="#1c1a17",
button_secondary_border_color="#1c1a17",
input_background_fill="#fffdf8",
input_border_color="#e5dfd2",
)
# ── Gradio UI ──────────────────────────────────────────────────────────────
with gr.Blocks(title="Budget Advisor", theme=APP_THEME, css=APP_CSS) as demo:
# Masthead
gr.HTML("""
<div class="ba-masthead">
<div>
<span class="ba-brand">Ledger &amp; Co.</span>
<span class="ba-brand-title">Personal Budget Advisor</span>
</div>
<span class="ba-meta">Monthly Β· USD</span>
</div>
<div class="ba-hero">
<h1>A measured view of your <em>monthly finances.</em></h1>
<p>Enter your income, pre-tax deductions, and expenses below. The report compiles a structured view of where your money goes β€” and packages it as a handoff you can take to any AI advisor for personalized guidance.</p>
</div>
""")
with gr.Row(equal_height=False):
# ─── LEFT: inputs ─────────────────────────────────────────────
with gr.Column(scale=5, min_width=420):
gr.HTML('<div class="ba-panel-head"><h2>Income</h2><span class="ba-idx">I.</span></div>')
gross_income = gr.Number(
label="Monthly gross income (USD)", value=5000, minimum=0, precision=0,
)
gr.HTML('<div class="ba-panel-head"><h2>Pre-tax deductions</h2><span class="ba-idx">II.</span></div>'
'<p class="ba-panel-note">Adjust to reflect your actual withholdings and retirement contributions.</p>')
with gr.Row():
fed_tax = gr.Slider(0, 50, value=22, step=0.5, label="Federal income tax (%)")
state_tax = gr.Slider(0, 15, value=5, step=0.5, label="State income tax (%)")
with gr.Row():
fica = gr.Slider(0, 10, value=6.2, step=0.1, label="Social Security / FICA (%)")
medicare = gr.Slider(0, 5, value=1.45, step=0.05, label="Medicare (%)")
k401 = gr.Slider(0, 30, value=6, step=0.5, label="401(k) contribution (%)")
gr.HTML('<div class="ba-panel-head"><h2>Monthly expenses</h2><span class="ba-idx">III.</span></div>'
'<p class="ba-panel-note">Recurring categories with rough recommended ceilings as a share of take-home.</p>')
with gr.Row():
housing = gr.Number(label="Housing (USD)", value=1200, minimum=0, precision=0)
food = gr.Number(label="Food & groceries (USD)", value=400, minimum=0, precision=0)
transport = gr.Number(label="Transport (USD)", value=200, minimum=0, precision=0)
with gr.Row():
utilities = gr.Number(label="Utilities (USD)", value=150, minimum=0, precision=0)
entertainment = gr.Number(label="Entertainment (USD)", value=100, minimum=0, precision=0)
health = gr.Number(label="Health (USD)", value=0, minimum=0, precision=0)
with gr.Row():
education = gr.Number(label="Education (USD)", value=0, minimum=0, precision=0)
clothing = gr.Number(label="Clothing (USD)", value=0, minimum=0, precision=0)
other = gr.Number(label="Other (USD)", value=0, minimum=0, precision=0)
calc_btn = gr.Button("Compile report", variant="primary", size="lg")
# ─── RIGHT: results sheet ─────────────────────────────────────
with gr.Column(scale=6, min_width=480):
report_html = gr.HTML(
value='<div class="ba-results-sheet">' + _empty_report() + '</div>',
)
# Handoff section
gr.HTML("""
<div class="ba-handoff-head">
<h2>Take this report further</h2>
<p>Your budget has been packaged as a structured handoff β€” a carefully written prompt followed by your figures as JSON. Paste the whole block into the AI assistant of your choice for a personalized read.</p>
<div class="ba-steps">
<div class="ba-step"><div class="ba-n">Step 01</div><h4>Verify your inputs</h4><p>Adjust the sliders and expense fields until the report reflects your actual situation.</p></div>
<div class="ba-step"><div class="ba-n">Step 02</div><h4>Copy the handoff</h4><p>Use the button below to copy the prompt and JSON in one click, or download the JSON as a file.</p></div>
<div class="ba-step"><div class="ba-n">Step 03</div><h4>Paste into your advisor</h4><p>Any capable assistant β€” Claude, ChatGPT, or your own β€” will produce an actionable read.</p></div>
</div>
</div>
""")
with gr.Accordion("Ready-to-paste prompt and budget data", open=True):
out_full_prompt = gr.Code(
label="Prompt + JSON (copy the whole block)",
language="markdown",
interactive=False,
lines=18,
)
with gr.Row():
copy_btn = gr.Button("Copy handoff to clipboard", variant="primary")
out_download = gr.File(label="Or download the JSON", interactive=False)
with gr.Accordion("Raw JSON only", open=False):
out_json = gr.Code(
label="Budget data as JSON",
language="json",
interactive=False,
lines=18,
)
gr.HTML('<div class="ba-footer"><span>Ledger &amp; Co. Β· Not professional financial advice</span><span>Report compiled locally in-browser</span></div>')
# ── wire up (computes on button click + on any input change) ───
inputs = [
gross_income,
fed_tax, state_tax, fica, medicare, k401,
housing, food, transport, utilities,
entertainment, health, education, clothing, other,
]
outputs = [report_html, out_json, out_full_prompt, out_download]
calc_btn.click(fn=calculate_budget, inputs=inputs, outputs=outputs)
# Live-update so the report reflects input changes without needing the button
for ctrl in inputs:
ctrl.change(fn=calculate_budget, inputs=inputs, outputs=outputs)
# compute initial report on load
demo.load(fn=calculate_budget, inputs=inputs, outputs=outputs)
# Clipboard copy runs in the browser β€” no server round-trip needed.
copy_btn.click(
fn=None,
inputs=[out_full_prompt],
outputs=[],
js="""
(text) => {
if (!text) {
alert("Adjust your inputs first, then try again.");
return;
}
navigator.clipboard.writeText(text).then(() => {
alert("Copied. Paste it into your AI assistant of choice.");
}).catch(() => {
alert("Couldn't copy automatically β€” select the text in the box and copy it manually.");
});
}
""",
)
if __name__ == "__main__":
demo.launch()