| """Spend Elegy — receipt -> categorized spend, a bar chart, and a short elegy. |
| |
| Pipeline: |
| input (paste | .txt | .pdf | image) |
| -> extract text (direct | PDF text-layer | MiniCPM-V-4.6 OCR for images) |
| -> text model -> strict categorized JSON (pipeline.py); non-receipt input is |
| rejected -> outputs: summary, line-items table, per-category bar chart, |
| per-category totals, an elegy (a 2nd text-model call), and the raw JSON. |
| |
| Deployment: a Hugging Face ZeroGPU Gradio Space. Models load at module scope |
| (CUDA on the Space) and generation runs inside @spaces.GPU. |
| |
| Local dev (Apple Silicon / no CUDA) — Nemotron-3 Nano's Mamba kernels are |
| CUDA-only, so use an MPS-friendly text model and skip the vision model: |
| |
| NORMALIZER_MODEL_ID=Qwen/Qwen2.5-3B-Instruct LOAD_VISION=0 python app.py |
| """ |
|
|
| from __future__ import annotations |
|
|
| import html |
| import json |
| import os |
|
|
| import gradio as gr |
| import pandas as pd |
| import spaces |
|
|
| import extraction |
| import models |
| import pipeline |
|
|
| GPU_DURATION = int(os.environ.get("GPU_DURATION", "180")) |
|
|
| CSS = """ |
| #se-elegy .se-card { |
| background: linear-gradient(160deg, #2a2440 0%, #1c2030 100%); |
| color: #e8e4f2; border: 1px solid #3b3656; border-radius: 14px; |
| padding: 20px 22px; min-height: 180px; |
| font-family: Georgia, "Times New Roman", serif; font-style: italic; |
| font-size: 15px; line-height: 1.75; text-align: center; |
| box-shadow: inset 0 0 50px rgba(0,0,0,.28); |
| } |
| #se-elegy .se-card .se-candle { font-style: normal; font-size: 20px; opacity: .8; } |
| .se-working { color: #7c83d8; font-weight: 600; animation: se-pulse 1.2s ease-in-out infinite; } |
| @keyframes se-pulse { 0%,100% { opacity: .4 } 50% { opacity: 1 } } |
| """ |
|
|
| PLACEHOLDER = "GREENLEAF MARKET\nOrganic Bananas 1.29\nWhole Milk 2L 2.49\n..." |
|
|
|
|
| def _elegy_html(elegy: str) -> str: |
| text = (elegy or "").replace("—", ", ").replace("–", "-").strip() |
| body = html.escape(text).replace("\n", "<br>") |
| if not body: |
| return "" |
| return f"<div class='se-card'><div class='se-candle'>🕯️</div>{body}</div>" |
|
|
|
|
| def _notice(message: str): |
| """Cleared outputs with a notice in the summary (errors / non-receipts).""" |
| empty_chart = pd.DataFrame(columns=["Category", "Total"]) |
| return message, [], empty_chart, [], "", "" |
|
|
|
|
| @spaces.GPU(duration=GPU_DURATION) |
| def run_inference(images, receipt_text: str) -> dict: |
| """GPU stage: OCR (if images) -> categorized JSON -> elegy.""" |
| if images: |
| try: |
| ocr_text = extraction.ocr_images(images) |
| except Exception as exc: |
| raise RuntimeError(f"couldn't read the image/PDF (OCR failed): {exc}") |
| receipt_text = f"{receipt_text}\n\n{ocr_text}".strip() if receipt_text else ocr_text |
|
|
| raw = models.text_model.generate(pipeline.build_messages(receipt_text)) |
| try: |
| data = pipeline.parse_json(raw) |
| except Exception: |
| raw = models.text_model.generate( |
| pipeline.build_messages(receipt_text, remind=True) |
| ) |
| data = pipeline.parse_json(raw) |
|
|
| record = pipeline.normalize_record(data) |
| if not record["is_receipt"] or not record["line_items"]: |
| return {"record": record, "elegy": None, "is_receipt": False} |
|
|
| elegy = models.text_model.generate(pipeline.build_elegy_messages(record)).strip() |
| return {"record": record, "elegy": elegy, "is_receipt": True} |
|
|
|
|
| def parse_receipt(file_path: str | None, pasted_text: str | None): |
| |
| if file_path: |
| try: |
| text, images = extraction.extract_from_file(file_path) |
| except ValueError as exc: |
| return _notice(f"⚠️ {exc}") |
| else: |
| text, images = (pasted_text or "").strip(), [] |
|
|
| if not text and not images: |
| return _notice("⚠️ Upload a .txt / .pdf / image receipt, or paste some text first.") |
|
|
| try: |
| result = run_inference(images, text) |
| except Exception as exc: |
| return _notice(f"⚠️ Sorry, couldn't read this one. ({exc})") |
|
|
| if not result.get("is_receipt", True): |
| return _notice( |
| "🤔 That doesn't look like a receipt, bill, or statement. Try a grocery " |
| "bill, a bank/card statement, or a note about money you spent." |
| ) |
|
|
| record = result["record"] |
| items = record["line_items"] |
| chart_df = pd.DataFrame(pipeline.category_totals(items), columns=["Category", "Total"]) |
| return ( |
| pipeline.summary_markdown(record), |
| pipeline.items_table(items), |
| chart_df, |
| pipeline.category_totals(items), |
| _elegy_html(result["elegy"]), |
| json.dumps(record, indent=2, ensure_ascii=False), |
| ) |
|
|
|
|
| def _show_working(): |
| return "<span class='se-working'>⏳ Reading your receipt and composing its elegy…</span>" |
|
|
|
|
| def _on_file_change(file_path): |
| |
| if file_path: |
| return gr.update( |
| value="", interactive=False, |
| placeholder="Using the uploaded file — remove it to paste text instead.", |
| ) |
| return gr.update(interactive=True, placeholder=PLACEHOLDER) |
|
|
|
|
| |
|
|
| theme = gr.themes.Soft(primary_hue="orange", neutral_hue="slate") |
|
|
| with gr.Blocks(title="Spend Elegy", theme=theme, css=CSS) as demo: |
| gr.Markdown( |
| "# 🧾 Spend Elegy\n" |
| "Turn a receipt, bill, or statement into a categorized spending breakdown " |
| "— with a little elegy for your money." |
| ) |
|
|
| with gr.Row(equal_height=True): |
| with gr.Column(): |
| text_input = gr.Textbox( |
| label="Paste receipt / statement text", lines=12, placeholder=PLACEHOLDER |
| ) |
| file_input = gr.File( |
| label="…or upload a file (.txt, .pdf, image)", |
| file_types=[".txt", ".pdf", ".png", ".jpg", ".jpeg", ".webp"], |
| type="filepath", |
| height=100, |
| ) |
| with gr.Row(): |
| parse_button = gr.Button("Parse receipt", variant="primary", scale=2) |
| gr.ClearButton([text_input, file_input], scale=1) |
| with gr.Column(): |
| summary_output = gr.Markdown() |
| elegy_output = gr.HTML(elem_id="se-elegy") |
|
|
| with gr.Row(equal_height=True): |
| chart_output = gr.BarPlot(x="Category", y="Total", title="Spend by category") |
| items_output = gr.Dataframe( |
| headers=["Item", "Qty", "Amount", "Category"], |
| datatype=["str", "number", "number", "str"], |
| label="Line items", |
| wrap=True, |
| ) |
|
|
| with gr.Accordion("Per-category totals & raw JSON", open=False): |
| with gr.Row(equal_height=True): |
| category_output = gr.Dataframe( |
| headers=["Category", "Total"], |
| datatype=["str", "number"], |
| label="Per-category totals", |
| ) |
| json_output = gr.Code(language="json", label="Raw JSON") |
|
|
| file_input.change(_on_file_change, inputs=file_input, outputs=text_input) |
|
|
| outputs = [ |
| summary_output, |
| items_output, |
| chart_output, |
| category_output, |
| elegy_output, |
| json_output, |
| ] |
| parse_button.click(_show_working, None, summary_output).then( |
| parse_receipt, inputs=[file_input, text_input], outputs=outputs |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|