trishpurkait's picture
Add BillStructAI demo
445c176
Raw
History Blame Contribute Delete
5.63 kB
"""
BillStructAI demo — paste noisy OCR text from an invoice/receipt and get
back structured JSON, using a Qwen2.5-1.5B-Instruct base model fine-tuned
with QLoRA for this task.
Deploy: push this file + requirements.txt to a Hugging Face Space
(Gradio SDK). Set ADAPTER_REPO below to your uploaded LoRA adapter repo.
"""
import json
import re
import spaces # add this import
import gradio as gr
import torch
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
BASE_MODEL_NAME = "Qwen/Qwen2.5-1.5B-Instruct"
ADAPTER_REPO = "trishpurkait/billstructai-qwen-lora" # <-- update after uploading adapter
SYSTEM_PROMPT = """
You are a precise invoice information extraction assistant.
Your task is to extract structured invoice data from noisy OCR text.
Return only valid JSON.
Do not explain.
Do not add markdown.
Use null for missing fields.
Do not hallucinate values that are not present in the OCR text.
""".strip()
EXAMPLE_OCR = """HAZLETON
REISSUE
LABORATORIES AMERICA, INC.
INVOICE
5510 NICHOLSON LANE, SUITE 400, KENSINGTON, MARYLAND 20095
PLEASE SEND PAYMENT TO:
INVOICE NO .: 812516
HAZLETON LABORATORIES AMERICA, INC.
USE THIS NUMBER
PO BOX 25065
FOR CHECK REMITTANCE
RICHMOND, VA
23260
USA
AND CORRESPONDENCE
BILLED TO:
DATE :
02-06-89
DR. J. D. HECK
LORILLARD RESEARCH CENTER
420 ENGLISH STREET
P.O. BOX 21688
GREENSBORO, NC 27420
SPONSOR REFERENCE : :selected:
IDENTIFICATION :
PURCHASE ORDER NO. 344A
HLA REFERENCE :
PROJECT AUTHORIZATION NO. :
1480
COST CENTER :
6130
PROJECT NUMBER :
20988
SERVICES RENDERED:
AMES SALMONELLA/MICROSOME REVERSE MUTATION ASSAY
IN TRIPLICATE
HLA REFERENCE
FINAL REPORT
NUMBER
TEST MATERIAL
MAILED
VALUE
10532 0 401
A105 BF7-142-3 BF9-135-2
11-08-88
1,300.00
10534 0 401
A167 OR117-89
11-15-88
1,300.00
TOTAL AMOUNT DUE UPON RECEIPT : :selected:
$2,600.00
OK-Ag Minnenyer
2-10-89
87147782
Dept. 8700
Acct. 4111
GENETICS AND IN VITRO TOXICOLOGY DEPARTMENT (301) 230-0001"""
_model = None
_tokenizer = None
def load_model():
"""Lazily load base model + LoRA adapter (4-bit) on first request."""
global _model, _tokenizer
if _model is not None:
return _model, _tokenizer
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
)
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL_NAME, trust_remote_code=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
base_model = AutoModelForCausalLM.from_pretrained(
BASE_MODEL_NAME,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
model = PeftModel.from_pretrained(base_model, ADAPTER_REPO)
model.eval()
_model, _tokenizer = model, tokenizer
return model, tokenizer
def extract_json_from_text(text: str):
"""Best-effort JSON extraction from model output (handles markdown fences)."""
if not text:
return None
text = text.strip()
try:
return json.loads(text)
except Exception:
pass
text = text.replace("```json", "").replace("```", "").strip()
try:
return json.loads(text)
except Exception:
pass
match = re.search(r"\{.*\}", text, re.DOTALL)
if match:
try:
return json.loads(match.group(0))
except Exception:
return None
return None
@spaces.GPU # add this decorator
def run_extraction(ocr_text: str) -> str:
if not ocr_text or not ocr_text.strip():
return "Paste some OCR text first."
model, tokenizer = load_model()
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": f"Extract invoice information from the OCR text below and "
f"return only valid JSON.\n\nOCR_TEXT:\n{ocr_text.strip()}",
},
]
prompt = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
output_ids = model.generate(
**inputs,
max_new_tokens=768,
do_sample=False,
pad_token_id=tokenizer.pad_token_id,
)
generated = tokenizer.decode(
output_ids[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
)
parsed = extract_json_from_text(generated)
if parsed is not None:
return json.dumps(parsed, indent=2, ensure_ascii=False)
return generated # fall back to raw output if parsing fails
with gr.Blocks(title="BillStructAI — Invoice OCR to JSON") as demo:
gr.Markdown(
"# BillStructAI\n"
"Paste noisy OCR text from an invoice or receipt below. "
"A Qwen2.5-1.5B-Instruct model fine-tuned with QLoRA extracts it into "
"structured JSON. [See the full writeup and evaluation results](https://github.com/trishpurkait/billstructai)."
)
with gr.Row():
with gr.Column():
ocr_input = gr.Textbox(
label="OCR text",
lines=12,
value=EXAMPLE_OCR,
placeholder="Paste raw OCR text here...",
)
submit_btn = gr.Button("Extract", variant="primary")
with gr.Column():
json_output = gr.Code(label="Extracted JSON", language="json", lines=20)
submit_btn.click(fn=run_extraction, inputs=ocr_input, outputs=json_output)
if __name__ == "__main__":
demo.launch()