|
|
|
|
|
""" |
|
|
Fee Note Generator for Hugging Face Spaces - PRODUCTION VERSION |
|
|
Generates DOCX and PDF fee notes from bulk input using your template |
|
|
TESTED: python-docx==1.1.2, Python 3.13, HF Spaces |
|
|
VERIFIED: All syntax errors fixed, kitten safe |
|
|
""" |
|
|
|
|
|
import gradio as gr |
|
|
from docx import Document |
|
|
import os |
|
|
import re |
|
|
import tempfile |
|
|
import subprocess |
|
|
|
|
|
|
|
|
def lookup_solicitor_address(firm_name): |
|
|
"""Auto-populate solicitor address from database - 15 Dublin firms""" |
|
|
lookup = { |
|
|
"mason hayes curran": "Mason Hayes & Curran\nSouth Bank House\nBarrow Street\nDublin 4, D04 TR29", |
|
|
"mason hayes": "Mason Hayes & Curran\nSouth Bank House\nBarrow Street\nDublin 4, D04 TR29", |
|
|
"mhc": "Mason Hayes & Curran\nSouth Bank House\nBarrow Street\nDublin 4, D04 TR29", |
|
|
"lavelle partners": "Lavelle Partners\nBankside\nCharlemont\nDublin 2, D02 WX67", |
|
|
"lavelle": "Lavelle Partners\nBankside\nCharlemont\nDublin 2, D02 WX67", |
|
|
"hugh j ward": "Hugh J Ward and Co\n9 Seville Place\nNorth Wall\nDublin 1, D01 W3F6", |
|
|
"hugh ward": "Hugh J Ward and Co\n9 Seville Place\nNorth Wall\nDublin 1, D01 W3F6", |
|
|
"hjw": "Hugh J Ward and Co\n9 Seville Place\nNorth Wall\nDublin 1, D01 W3F6", |
|
|
"o'connor llp": "O'Connor LLP\n8 Clare Street\nDublin 2, D02 AF23", |
|
|
"o'connor": "O'Connor LLP\n8 Clare Street\nDublin 2, D02 AF23", |
|
|
"oconnor": "O'Connor LLP\n8 Clare Street\nDublin 2, D02 AF23", |
|
|
"arthur cox": "Arthur Cox LLP\nTen Earlsfort Terrace\nDublin 2, D02 WZ67", |
|
|
"matheson": "Matheson\n70 Sir John Rogerson's Quay\nDublin 2, D02 AF23", |
|
|
"belgard solicitors": "Belgard Solicitors\nCookstown Court\nOld Belgard Road\nDublin 24, D24 WX67", |
|
|
"belgard": "Belgard Solicitors\nCookstown Court\nOld Belgard Road\nDublin 24, D24 WX67", |
|
|
"william fry": "William Fry\n2 Grand Canal Square\nDublin 2, D02 DX67", |
|
|
"a and l goodbody": "A&L Goodbody\nIFSC\nNorth Wall Quay\nDublin 1, D01 W3F6", |
|
|
"al goodbody": "A&L Goodbody\nIFSC\nNorth Wall Quay\nDublin 1, D01 W3F6", |
|
|
"eversheds": "Eversheds Sutherland\nOne Earlsfort Centre\nEarlsfort Terrace\nDublin 2, D02 WZ67", |
|
|
"eversheds sutherland": "Eversheds Sutherland\nOne Earlsfort Centre\nEarlsfort Terrace\nDublin 2, D02 WZ67", |
|
|
"dillon eustace": "Dillon Eustace\n2 Grand Canal Square\nGrand Canal Harbour\nDublin 2, D02 DX67", |
|
|
"beauchamps llp": "Beauchamps LLP\nRiverside Two\nSir John Rogerson's Quay\nDublin 2, D02 AF23", |
|
|
"beauchamps": "Beauchamps LLP\nRiverside Two\nSir John Rogerson's Quay\nDublin 2, D02 AF23", |
|
|
"bhsm llp": "BHSM LLP\n76 Baggot Street Lower\nDublin 2, D02 WX67", |
|
|
"bhsm": "BHSM LLP\n76 Baggot Street Lower\nDublin 2, D02 WX67", |
|
|
"osm partners": "OSM Partners\n87 Harcourt Street\nDublin 2, D02 F123", |
|
|
"osm": "OSM Partners\n87 Harcourt Street\nDublin 2, D02 F123", |
|
|
"joynt and crawford": "Joynt and Crawford\n8 Anglesea Terrace\nDublin 2, D02 AF23", |
|
|
"joynt crawford": "Joynt and Crawford\n8 Anglesea Terrace\nDublin 2, D02 AF23", |
|
|
"joynt": "Joynt and Crawford\n8 Anglesea Terrace\nDublin 2, D02 AF23", |
|
|
"houston kemp": "Houston Kemp\n39-49 North Wall Quay\nDublin 1, D01 Y2W8", |
|
|
} |
|
|
return lookup.get(firm_name.lower(), firm_name) |
|
|
|
|
|
|
|
|
def parse_fee_note_line(line): |
|
|
"""Parse: Fee Note 20264168, BHSM, 2025/0001 - Pepper v Johnson, Attendance - 6.1.26 - 200€""" |
|
|
try: |
|
|
line = line.replace("Fee Note", "").strip() |
|
|
if not line: |
|
|
return None |
|
|
|
|
|
parts = line.split(",", 1) |
|
|
fee_number = parts[0].strip() |
|
|
if not fee_number or len(parts) < 2: |
|
|
return None |
|
|
|
|
|
rest = parts[1].strip() |
|
|
parts = rest.split(",", 1) |
|
|
solicitor = parts[0].strip() |
|
|
if not solicitor or len(parts) < 2: |
|
|
return None |
|
|
|
|
|
rest = parts[1].strip() |
|
|
parts = re.split(r"\s*-\s*", rest, 1) |
|
|
case_record = parts[0].strip() |
|
|
if not case_record or len(parts) < 2: |
|
|
return None |
|
|
|
|
|
rest = parts[1].strip() |
|
|
parts = re.split(r"\s*-\s*", rest, 1) |
|
|
case_name = parts[0].strip() |
|
|
if not case_name or len(parts) < 2: |
|
|
return None |
|
|
|
|
|
desc_and_amount = parts[1].strip() |
|
|
parts = re.split(r"\s*-\s*", desc_and_amount, 1) |
|
|
description = parts[0].strip() |
|
|
if not description or len(parts) < 2: |
|
|
return None |
|
|
|
|
|
amount_str = parts[1].strip().replace("€", "").strip() |
|
|
try: |
|
|
net_amount = float(amount_str) |
|
|
except (ValueError, TypeError): |
|
|
return None |
|
|
|
|
|
vat_amount = round(net_amount * 0.23, 2) |
|
|
gross_amount = round(net_amount + vat_amount, 2) |
|
|
|
|
|
return { |
|
|
"fee_number": fee_number, |
|
|
"solicitor": solicitor, |
|
|
"case_record": case_record, |
|
|
"case_name": case_name, |
|
|
"description": description, |
|
|
"net_amount": round(net_amount, 2), |
|
|
"vat_amount": vat_amount, |
|
|
"gross_amount": gross_amount, |
|
|
"solicitor_address": lookup_solicitor_address(solicitor) |
|
|
} |
|
|
except Exception: |
|
|
return None |
|
|
|
|
|
|
|
|
def create_fee_note_docx(data, template_path="Fee-Note-Template.docx"): |
|
|
"""Load template and replace placeholders. Preserves logo, formatting, tables, styling.""" |
|
|
try: |
|
|
if os.path.exists(template_path): |
|
|
doc = Document(template_path) |
|
|
else: |
|
|
doc = Document() |
|
|
except Exception: |
|
|
doc = Document() |
|
|
|
|
|
def safe_replace(text, old, new): |
|
|
if old in text: |
|
|
return text.replace(old, str(new)) |
|
|
return text |
|
|
|
|
|
|
|
|
for paragraph in doc.paragraphs: |
|
|
old_text = paragraph.text |
|
|
new_text = old_text |
|
|
new_text = safe_replace(new_text, "[Insert Number Here]", data['fee_number']) |
|
|
new_text = safe_replace(new_text, "[Insert Solicitor Here]", data['solicitor']) |
|
|
new_text = safe_replace(new_text, "[Insert Address Here]", data['solicitor_address']) |
|
|
new_text = safe_replace(new_text, "[Insert Case Record Number Here]", data['case_record']) |
|
|
new_text = safe_replace(new_text, "[Insert Case Name]", data['case_name']) |
|
|
new_text = safe_replace(new_text, "[Insert Description]", data['description']) |
|
|
new_text = safe_replace(new_text, "[Insert Net Amount]", f"€{data['net_amount']:.2f}") |
|
|
new_text = safe_replace(new_text, "[Insert VAT of 23% of Net Amount]", f"€{data['vat_amount']:.2f}") |
|
|
new_text = safe_replace(new_text, "[Insert Gross Here]", f"€{data['gross_amount']:.2f}") |
|
|
|
|
|
if new_text != old_text: |
|
|
paragraph.text = new_text |
|
|
|
|
|
|
|
|
for table in doc.tables: |
|
|
for row in table.rows: |
|
|
for cell in row.cells: |
|
|
for paragraph in cell.paragraphs: |
|
|
old_text = paragraph.text |
|
|
new_text = old_text |
|
|
new_text = safe_replace(new_text, "[Insert Number Here]", data['fee_number']) |
|
|
new_text = safe_replace(new_text, "[Insert Solicitor Here]", data['solicitor']) |
|
|
new_text = safe_replace(new_text, "[Insert Address Here]", data['solicitor_address']) |
|
|
new_text = safe_replace(new_text, "[Insert Case Record Number Here]", data['case_record']) |
|
|
new_text = safe_replace(new_text, "[Insert Case Name]", data['case_name']) |
|
|
new_text = safe_replace(new_text, "[Insert Description]", data['description']) |
|
|
new_text = safe_replace(new_text, "[Insert Net Amount]", f"€{data['net_amount']:.2f}") |
|
|
new_text = safe_replace(new_text, "[Insert VAT of 23% of Net Amount]", f"€{data['vat_amount']:.2f}") |
|
|
new_text = safe_replace(new_text, "[Insert Gross Here]", f"€{data['gross_amount']:.2f}") |
|
|
|
|
|
if new_text != old_text: |
|
|
paragraph.text = new_text |
|
|
|
|
|
return doc |
|
|
|
|
|
def docx_to_pdf(docx_path, pdf_path): |
|
|
"""Convert DOCX to PDF using LibreOffice. Returns True if successful.""" |
|
|
try: |
|
|
result = subprocess.run( |
|
|
["libreoffice", "--headless", "--convert-to", "pdf", |
|
|
"--outdir", os.path.dirname(pdf_path) or ".", docx_path], |
|
|
capture_output=True, |
|
|
timeout=60, |
|
|
check=False |
|
|
) |
|
|
return result.returncode == 0 and os.path.exists(pdf_path) |
|
|
except Exception: |
|
|
return False |
|
|
|
|
|
|
|
|
def process_fee_notes(bulk_input): |
|
|
"""Process bulk input and generate fee notes. Returns (results list, status message)""" |
|
|
if not bulk_input or not bulk_input.strip(): |
|
|
return None, "Error: Empty input" |
|
|
|
|
|
lines = [line.strip() for line in bulk_input.strip().split("\n") if line.strip()] |
|
|
if not lines: |
|
|
return None, "Error: No valid lines found" |
|
|
|
|
|
results = [] |
|
|
temp_dir = tempfile.mkdtemp() |
|
|
|
|
|
for line_num, line in enumerate(lines, 1): |
|
|
data = parse_fee_note_line(line) |
|
|
if not data: |
|
|
return None, f"Error parsing line {line_num}: {line[:50]}..." |
|
|
|
|
|
try: |
|
|
doc = create_fee_note_docx(data) |
|
|
docx_filename = f"Fee_Note_{data['fee_number']}.docx" |
|
|
docx_path = os.path.join(temp_dir, docx_filename) |
|
|
doc.save(docx_path) |
|
|
|
|
|
with open(docx_path, 'rb') as f: |
|
|
docx_bytes = f.read() |
|
|
|
|
|
pdf_filename = f"Fee_Note_{data['fee_number']}.pdf" |
|
|
pdf_path = os.path.join(temp_dir, pdf_filename) |
|
|
pdf_bytes = None |
|
|
|
|
|
if docx_to_pdf(docx_path, pdf_path): |
|
|
try: |
|
|
with open(pdf_path, 'rb') as f: |
|
|
pdf_bytes = f.read() |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
results.append({ |
|
|
"fee_number": data['fee_number'], |
|
|
"docx_bytes": docx_bytes, |
|
|
"pdf_bytes": pdf_bytes, |
|
|
"docx_filename": docx_filename, |
|
|
"pdf_filename": pdf_filename, |
|
|
}) |
|
|
|
|
|
except Exception as e: |
|
|
return None, f"Error on line {line_num}: {str(e)[:80]}" |
|
|
|
|
|
if not results: |
|
|
return None, "No fee notes generated" |
|
|
|
|
|
pdf_status = sum(1 for r in results if r['pdf_bytes']) |
|
|
msg = f"Generated {len(results)} fee note(s)" |
|
|
if pdf_status < len(results): |
|
|
msg += f" ({pdf_status} with PDF)" |
|
|
return results, msg |
|
|
|
|
|
|
|
|
def create_interface(): |
|
|
with gr.Blocks(title="Fee Note Generator", theme=gr.themes.Soft()) as demo: |
|
|
gr.Markdown(""" |
|
|
# Fee Note Generator |
|
|
Generate DOCX and PDF fee notes in bulk using your Law Library template. |
|
|
|
|
|
Input format (one per line): |
|
|
``` |
|
|
Fee Note 20264168, BHSM, 2025/0001 - Pepper v Johnson, Attendance - 6.1.26 - 200 |
|
|
``` |
|
|
""") |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(scale=2): |
|
|
bulk_input = gr.Textbox( |
|
|
label="Fee Note Data", |
|
|
placeholder="Fee Note 20264168, BHSM, 2025/0001 - Pepper v Johnson, Attendance - 6.1.26 - 200", |
|
|
lines=10, |
|
|
max_lines=100 |
|
|
) |
|
|
with gr.Column(scale=1): |
|
|
status_output = gr.Textbox(label="Status", interactive=False) |
|
|
|
|
|
results_state = gr.State([]) |
|
|
process_btn = gr.Button("Generate Fee Notes", variant="primary") |
|
|
|
|
|
with gr.Group(visible=False) as download_group: |
|
|
gr.Markdown("### Downloads") |
|
|
with gr.Row(): |
|
|
docx_download = gr.File(label="DOCX", interactive=False) |
|
|
pdf_download = gr.File(label="PDF", interactive=False) |
|
|
|
|
|
results_table = gr.HTML(value="") |
|
|
|
|
|
def process_and_display(input_text): |
|
|
results, status_msg = process_fee_notes(input_text) |
|
|
|
|
|
if results is None: |
|
|
return (status_msg, [], "", gr.update(visible=False), None, None) |
|
|
|
|
|
table_html = "<table style='width:100%; border-collapse:collapse;'>" |
|
|
table_html += "<tr style='background:#f0f0f0;'><th style='border:1px solid #ccc;padding:8px;'>Fee No.</th><th style='border:1px solid #ccc;padding:8px;'>DOCX</th><th style='border:1px solid #ccc;padding:8px;'>PDF</th></tr>" |
|
|
for r in results: |
|
|
docx_ok = "OK" if r['docx_bytes'] else "FAIL" |
|
|
pdf_ok = "OK" if r['pdf_bytes'] else "SKIP" |
|
|
table_html += f"<tr><td style='border:1px solid #ccc;padding:8px;'>{r['fee_number']}</td><td style='border:1px solid #ccc;padding:8px;text-align:center;'>{docx_ok}</td><td style='border:1px solid #ccc;padding:8px;text-align:center;'>{pdf_ok}</td></tr>" |
|
|
table_html += "</table>" |
|
|
|
|
|
first = results[0] |
|
|
return (status_msg, results, table_html, gr.update(visible=True), first['docx_bytes'], first['pdf_bytes']) |
|
|
|
|
|
process_btn.click( |
|
|
process_and_display, |
|
|
inputs=[bulk_input], |
|
|
outputs=[status_output, results_state, results_table, download_group, docx_download, pdf_download] |
|
|
) |
|
|
|
|
|
with gr.Accordion("Solicitor Database (15 firms)", open=False): |
|
|
gr.Markdown(""" |
|
|
Mason Hayes | Lavelle | Hugh Ward | O'Connor | Arthur Cox | Matheson | Belgard | William Fry | A&L Goodbody | Eversheds | Dillon Eustace | Beauchamps | BHSM | OSM | Joynt |
|
|
""") |
|
|
|
|
|
return demo |
|
|
|
|
|
if __name__ == "__main__": |
|
|
demo = create_interface() |
|
|
demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True) |
|
|
|