Spaces:
Sleeping
Sleeping
| import os | |
| import re | |
| import subprocess | |
| import tempfile | |
| import zipfile | |
| import csv | |
| from datetime import datetime | |
| from io import StringIO | |
| from difflib import SequenceMatcher | |
| import gradio as gr | |
| from docx import Document | |
| TEMPLATE_PATH = "Fee-Note-Template.docx" | |
| ADDRESSES_PATH = "Solicitors-Addresses.txt" | |
| # ── Solicitor address loading & fuzzy matching ────────────────────────────── | |
| def load_solicitor_addresses(): | |
| """ | |
| Parse Solicitors-Addresses.txt → dict of {canonical_name: address}. | |
| Also builds a lookup index keyed by normalised name for fuzzy matching. | |
| """ | |
| addresses = {} | |
| if not os.path.exists(ADDRESSES_PATH): | |
| return addresses | |
| with open(ADDRESSES_PATH, "r", encoding="utf-8") as f: | |
| content = f.read() | |
| blocks = content.strip().split("\n\n") | |
| for block in blocks: | |
| lines = block.strip().split("\n") | |
| if not lines: | |
| continue | |
| firm_name = lines[0].strip() | |
| address = "" | |
| for line in lines[1:]: | |
| if line.strip().startswith("Address:"): | |
| address = line.replace("Address:", "").strip() | |
| break | |
| if firm_name and address: | |
| addresses[firm_name] = address | |
| return addresses | |
| # Common legal entity suffixes to strip for matching purposes | |
| _LEGAL_SUFFIXES = re.compile( | |
| r"\b(llp|llc|ltd|limited|plc|dac|clg|lp|inc|incorporated|solicitors?)\b", | |
| re.IGNORECASE, | |
| ) | |
| def normalise_firm_name(name): | |
| """ | |
| Normalise a firm name for comparison: | |
| - lowercase | |
| - '&' ↔ 'and' | |
| - strip punctuation (dots, commas, apostrophes) | |
| - strip common legal suffixes (LLP, Ltd, Solicitors, etc.) | |
| - collapse whitespace | |
| """ | |
| s = name.lower().strip() | |
| # Normalise ampersand ↔ 'and' | |
| s = s.replace(" & ", " and ").replace("&", " and ") | |
| # Strip common punctuation | |
| s = re.sub(r"[.,;:'\u2018\u2019\u201C\u201D\"()]+", "", s) | |
| # Strip legal suffixes (LLP, Ltd, Solicitors, etc.) | |
| s = _LEGAL_SUFFIXES.sub("", s) | |
| # Collapse whitespace | |
| s = re.sub(r"\s+", " ", s).strip() | |
| return s | |
| def find_solicitor(input_name, addresses_dict): | |
| """ | |
| Try to match input_name against canonical solicitor names. | |
| Returns (canonical_name, address) or raises ValueError. | |
| Matching strategy (in order): | |
| 1. Exact match (case-insensitive) | |
| 2. Normalised exact match (& ↔ and, strip punctuation) | |
| 3. Substring containment (either direction) | |
| 4. Token overlap ≥ 70% | |
| 5. SequenceMatcher ratio ≥ 0.80 | |
| """ | |
| if not input_name: | |
| raise ValueError("Solicitor name is empty") | |
| # 1. Exact case-insensitive | |
| for canonical, addr in addresses_dict.items(): | |
| if input_name.lower() == canonical.lower(): | |
| return canonical, addr | |
| # 2. Normalised exact | |
| input_norm = normalise_firm_name(input_name) | |
| for canonical, addr in addresses_dict.items(): | |
| if input_norm == normalise_firm_name(canonical): | |
| return canonical, addr | |
| # 3. Substring containment | |
| for canonical, addr in addresses_dict.items(): | |
| canon_norm = normalise_firm_name(canonical) | |
| if input_norm in canon_norm or canon_norm in input_norm: | |
| return canonical, addr | |
| # 4. Token overlap | |
| input_tokens = set(input_norm.split()) | |
| best_overlap = 0 | |
| best_match = None | |
| for canonical, addr in addresses_dict.items(): | |
| canon_tokens = set(normalise_firm_name(canonical).split()) | |
| if not canon_tokens: | |
| continue | |
| overlap = len(input_tokens & canon_tokens) / max(len(input_tokens), len(canon_tokens)) | |
| if overlap > best_overlap: | |
| best_overlap = overlap | |
| best_match = (canonical, addr) | |
| if best_overlap >= 0.70 and best_match: | |
| return best_match | |
| # 5. SequenceMatcher | |
| best_ratio = 0 | |
| best_match = None | |
| for canonical, addr in addresses_dict.items(): | |
| ratio = SequenceMatcher(None, input_norm, normalise_firm_name(canonical)).ratio() | |
| if ratio > best_ratio: | |
| best_ratio = ratio | |
| best_match = (canonical, addr) | |
| if best_ratio >= 0.80 and best_match: | |
| return best_match | |
| # No match — build helpful error | |
| # Show top 3 closest matches | |
| scored = [] | |
| for canonical in addresses_dict: | |
| ratio = SequenceMatcher(None, input_norm, normalise_firm_name(canonical)).ratio() | |
| scored.append((canonical, ratio)) | |
| scored.sort(key=lambda x: x[1], reverse=True) | |
| suggestions = [f" • {name} ({ratio:.0%})" for name, ratio in scored[:3]] | |
| raise ValueError( | |
| f"Solicitor '{input_name}' not found.\n" | |
| f"Closest matches:\n" + "\n".join(suggestions) + "\n\n" | |
| f"You can add this firm using the 'Add New Solicitor' section below." | |
| ) | |
| # ── Cross-run text replacement ────────────────────────────────────────────── | |
| def replace_in_paragraph(paragraph, old_text, new_text): | |
| """ | |
| Replace text across runs in a paragraph while preserving formatting | |
| of the first run. Handles placeholders split across multiple runs. | |
| """ | |
| full_text = paragraph.text | |
| if old_text not in full_text: | |
| return False | |
| new_full = full_text.replace(old_text, new_text) | |
| runs = paragraph.runs | |
| if not runs: | |
| return False | |
| # Preserve first run's formatting, put all text there, clear the rest | |
| runs[0].text = new_full | |
| for r in runs[1:]: | |
| r.text = "" | |
| return True | |
| def apply_replacements(doc, replacements): | |
| """Apply all replacements across paragraphs and table cells.""" | |
| for old, new in replacements.items(): | |
| # Top-level paragraphs | |
| for p in doc.paragraphs: | |
| replace_in_paragraph(p, old, new) | |
| # Table cells | |
| for table in doc.tables: | |
| for row in table.rows: | |
| for cell in row.cells: | |
| for p in cell.paragraphs: | |
| replace_in_paragraph(p, old, new) | |
| # ── VAT alignment fix ─────────────────────────────────────────────────────── | |
| def fix_vat_alignment(doc): | |
| """ | |
| After replacements, the VAT amount can end up on a different visual line | |
| than the '+VAT @ 23%' label because the description text wraps. | |
| Fix: set the amount cell to bottom-vertical-align and rebuild it with | |
| just three paragraphs (net → spacer → VAT). The VAT amount then sits | |
| at the bottom of the cell, beside the '+VAT @ 23%' label. | |
| """ | |
| from docx.oxml.ns import qn | |
| ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'} | |
| for table in doc.tables: | |
| for row in table.rows: | |
| cells = row.cells | |
| # Find the description cell that contains '+VAT @ 23%' | |
| desc_cell = None | |
| for cell in cells: | |
| for p in cell.paragraphs: | |
| if '+VAT @' in p.text: | |
| desc_cell = cell | |
| break | |
| if desc_cell: | |
| break | |
| if not desc_cell: | |
| continue | |
| # Find the amount cell (different element from desc_cell, contains €) | |
| amt_cell = None | |
| for cell in cells: | |
| if cell._element is desc_cell._element: | |
| continue | |
| for p in cell.paragraphs: | |
| if '€' in p.text: | |
| amt_cell = cell | |
| break | |
| if amt_cell: | |
| break | |
| if not amt_cell: | |
| continue | |
| # Extract net and VAT amounts | |
| amt_paras = amt_cell._element.findall('w:p', ns) | |
| net_text = None | |
| net_para_ref = None | |
| vat_text = None | |
| vat_para_ref = None | |
| for p in amt_paras: | |
| text = ''.join(t.text or '' for t in p.findall('.//w:t', ns)).strip() | |
| if '€' in text and net_text is None: | |
| net_text = text | |
| net_para_ref = p | |
| elif '€' in text: | |
| vat_text = text | |
| vat_para_ref = p | |
| if not net_text or not vat_text: | |
| continue | |
| # ── Set bottom vertical alignment on the amount cell ── | |
| tcPr = amt_cell._element.find('w:tcPr', ns) | |
| if tcPr is None: | |
| tcPr = amt_cell._element.makeelement(qn('w:tcPr'), {}) | |
| amt_cell._element.insert(0, tcPr) | |
| # Remove any existing vAlign | |
| for old_va in tcPr.findall('w:vAlign', ns): | |
| tcPr.remove(old_va) | |
| vAlign = tcPr.makeelement(qn('w:vAlign'), {qn('w:val'): 'bottom'}) | |
| tcPr.append(vAlign) | |
| # ── Rebuild cell paragraphs: net → spacer → VAT ── | |
| for p in amt_paras: | |
| amt_cell._element.remove(p) | |
| def _make_centered_para(text, fmt_ref): | |
| """Create a centered paragraph with the given text, copying run formatting.""" | |
| p = amt_cell._element.makeelement(qn('w:p'), {}) | |
| pPr = p.makeelement(qn('w:pPr'), {}) | |
| jc = pPr.makeelement(qn('w:jc'), {qn('w:val'): 'center'}) | |
| pPr.append(jc) | |
| p.append(pPr) | |
| r = p.makeelement(qn('w:r'), {}) | |
| if fmt_ref is not None: | |
| orig_runs = fmt_ref.findall('.//w:r', ns) | |
| if orig_runs: | |
| rPr = orig_runs[0].find('w:rPr', ns) | |
| if rPr is not None: | |
| r.append(rPr.__deepcopy__(True)) | |
| t = r.makeelement(qn('w:t'), {}) | |
| t.text = text | |
| t.set(qn('xml:space'), 'preserve') | |
| r.append(t) | |
| p.append(r) | |
| return p | |
| # Para 0: net amount | |
| amt_cell._element.append(_make_centered_para(net_text, net_para_ref)) | |
| # Para 1: empty spacer | |
| amt_cell._element.append(amt_cell._element.makeelement(qn('w:p'), {})) | |
| # Para 2: VAT amount | |
| amt_cell._element.append(_make_centered_para(vat_text, vat_para_ref)) | |
| return # done — only one such row per fee note | |
| # ── PDF conversion ────────────────────────────────────────────────────────── | |
| def convert_docx_to_pdf(docx_path, output_dir): | |
| """ | |
| Convert a .docx to .pdf using LibreOffice. | |
| Returns the path to the generated PDF. | |
| """ | |
| try: | |
| subprocess.run( | |
| [ | |
| "libreoffice", "--headless", "--norestore", | |
| "--convert-to", "pdf", | |
| "--outdir", output_dir, | |
| docx_path, | |
| ], | |
| capture_output=True, | |
| timeout=30, | |
| check=True, | |
| ) | |
| pdf_name = os.path.splitext(os.path.basename(docx_path))[0] + ".pdf" | |
| pdf_path = os.path.join(output_dir, pdf_name) | |
| if os.path.exists(pdf_path): | |
| return pdf_path | |
| except Exception: | |
| pass | |
| return None | |
| # ── Date formatting ───────────────────────────────────────────────────────── | |
| def format_date_for_fee_note(dt=None): | |
| """ | |
| Format date as '6 March 2026' (no leading zero on day). | |
| """ | |
| if dt is None: | |
| dt = datetime.today() | |
| return f"{dt.day} {dt.strftime('%B')} {dt.year}" | |
| # ── Main generation logic ─────────────────────────────────────────────────── | |
| def generate_batch_fee_notes(csv_text): | |
| """ | |
| Process pipe-delimited input and generate fee note DOCX + PDF files. | |
| Required columns: | |
| filename | solicitor_name | case_record | case_name | description | net_amount | |
| """ | |
| addresses_lookup = load_solicitor_addresses() | |
| if not csv_text.strip(): | |
| raise gr.Error("Input is empty. Please paste at least one row of data.") | |
| # Check if user included a header row | |
| lines = csv_text.strip().split("\n") | |
| first_line_lower = lines[0].lower() | |
| has_header = "filename" in first_line_lower and "solicitor" in first_line_lower | |
| if not has_header: | |
| # Prepend the header row automatically | |
| csv_text = "filename | solicitor_name | case_record | case_name | description | net_amount\n" + csv_text | |
| csv_reader = csv.DictReader( | |
| StringIO(csv_text), | |
| delimiter="|", | |
| quotechar='"', | |
| ) | |
| if not csv_reader.fieldnames: | |
| raise gr.Error("Could not parse input. Check the format and try again.") | |
| csv_reader.fieldnames = [h.strip() for h in csv_reader.fieldnames] | |
| required_cols = {"filename", "solicitor_name", "case_record", "case_name", "description", "net_amount"} | |
| provided_cols = set(csv_reader.fieldnames) | |
| if not required_cols.issubset(provided_cols): | |
| missing = required_cols - provided_cols | |
| raise gr.Error( | |
| f"Missing required columns: {', '.join(missing)}\n" | |
| f"Required: filename | solicitor_name | case_record | case_name | description | net_amount" | |
| ) | |
| if not os.path.exists(TEMPLATE_PATH): | |
| raise gr.Error( | |
| f"Template '{TEMPLATE_PATH}' not found. " | |
| f"Please upload Fee-Note-Template.docx to the Space." | |
| ) | |
| tmpdir = tempfile.mkdtemp() | |
| output_files = [] | |
| today_str = format_date_for_fee_note() | |
| errors = [] | |
| rows = list(csv_reader) | |
| if not rows: | |
| raise gr.Error("No data rows found. Please check your input.") | |
| for row_num, row in enumerate(rows, start=1): | |
| try: | |
| row = {k: (v.strip() if v else "") for k, v in row.items()} | |
| filename = row["filename"].strip() | |
| if not filename: | |
| errors.append(f"Row {row_num}: filename is empty, skipped.") | |
| continue | |
| fee_note_number = filename | |
| docx_name = filename if filename.endswith(".docx") else filename + ".docx" | |
| solicitor_name = row["solicitor_name"].strip() | |
| canonical_name, address = find_solicitor(solicitor_name, addresses_lookup) | |
| # Parse net amount | |
| raw_net = row["net_amount"].replace("€", "").replace(",", "").strip() | |
| if not raw_net: | |
| errors.append(f"Row {row_num}: net_amount is empty, skipped.") | |
| continue | |
| net_value = float(raw_net) | |
| vat_value = round(net_value * 0.23, 2) | |
| gross_value = round(net_value + vat_value, 2) | |
| net_str = f"€{net_value:,.2f}" | |
| vat_str = f"€{vat_value:,.2f}" | |
| gross_str = f"€{gross_value:,.2f}" | |
| # Load template and apply replacements | |
| doc = Document(TEMPLATE_PATH) | |
| replacements = { | |
| "[Insert Todays Date Here]": today_str, | |
| "[Insert Solicitor Here]": canonical_name, | |
| "[Insert Address Here]": address, | |
| "[Insert Case Record Number]": row["case_record"].strip(), | |
| "[Insert Case Name]": row["case_name"].strip(), | |
| "[Insert Description]": row["description"].strip(), | |
| "[Insert Net Amount]": net_str, | |
| "[Insert VAT at .23% of Net Amount]": vat_str, | |
| "[Insert Gross Here]": gross_str, | |
| "[Insert Number Here]": fee_note_number, | |
| } | |
| apply_replacements(doc, replacements) | |
| fix_vat_alignment(doc) | |
| docx_path = os.path.join(tmpdir, docx_name) | |
| doc.save(docx_path) | |
| output_files.append((docx_name, docx_path)) | |
| # Convert to PDF | |
| pdf_path = convert_docx_to_pdf(docx_path, tmpdir) | |
| if pdf_path: | |
| pdf_name = os.path.splitext(docx_name)[0] + ".pdf" | |
| output_files.append((pdf_name, pdf_path)) | |
| except ValueError as e: | |
| errors.append(f"Row {row_num}: {str(e)}") | |
| except Exception as e: | |
| errors.append(f"Row {row_num}: unexpected error — {str(e)}") | |
| if not output_files: | |
| error_msg = "No fee notes were generated.\n\n" + "\n".join(errors) if errors else "No fee notes were generated." | |
| raise gr.Error(error_msg) | |
| # Create zip | |
| zip_path = os.path.join(tmpdir, "Fee-Notes.zip") | |
| with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: | |
| for display_name, file_path in output_files: | |
| zf.write(file_path, arcname=display_name) | |
| status_parts = [f"Generated {len([f for f in output_files if f[0].endswith('.docx')])} fee notes (DOCX + PDF)."] | |
| if errors: | |
| status_parts.append("\n**Warnings:**\n" + "\n".join(f"- {e}" for e in errors)) | |
| return zip_path, "\n".join(status_parts) | |
| # ── Add new solicitor ─────────────────────────────────────────────────────── | |
| def add_solicitor(firm_name, firm_address, firm_domain, firm_email_notes): | |
| """Append a new solicitor to Solicitors-Addresses.txt.""" | |
| if not firm_name.strip(): | |
| raise gr.Error("Firm name is required.") | |
| if not firm_address.strip(): | |
| raise gr.Error("Address is required.") | |
| block = f"\n\n{firm_name.strip()}\nAddress: {firm_address.strip()}" | |
| if firm_domain.strip(): | |
| block += f"\nDomain: {firm_domain.strip()}" | |
| if firm_email_notes.strip(): | |
| block += f"\nEmail return always: {firm_email_notes.strip()}" | |
| with open(ADDRESSES_PATH, "a", encoding="utf-8") as f: | |
| f.write(block + "\n") | |
| # Reload and return updated list | |
| addresses = load_solicitor_addresses() | |
| firms_list = "\n".join(f"• {name} — {addr}" for name, addr in sorted(addresses.items())) | |
| return f"Added **{firm_name.strip()}** successfully.\n\n**Current firms ({len(addresses)}):**\n{firms_list}" | |
| def get_current_firms(): | |
| """Return formatted list of current firms.""" | |
| addresses = load_solicitor_addresses() | |
| if not addresses: | |
| return "No firms loaded. Check that Solicitors-Addresses.txt exists in the Space." | |
| return "\n".join(f"• **{name}** — {addr}" for name, addr in sorted(addresses.items())) | |
| # ── Gradio UI ─────────────────────────────────────────────────────────────── | |
| with gr.Blocks(title="Fee Note Generator", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown( | |
| """ | |
| # Fee Note Generator | |
| Paste pipe-delimited fee note data below. Each row generates a DOCX and PDF fee note from your template. | |
| **Format** (header row is optional — it will be added automatically if missing): | |
| ``` | |
| filename | solicitor_name | case_record | case_name | description | net_amount | |
| ``` | |
| - **filename**: becomes the file name and fee note number (e.g. `20264402` → `20264402.docx` / `.pdf`) | |
| - **solicitor_name**: matched against the solicitor directory (fuzzy matching handles minor variations) | |
| - **net_amount**: numeric, e.g. `200` or `1000.00` — VAT (23%) and gross are calculated automatically | |
| - **Date**: today's date is inserted automatically | |
| """ | |
| ) | |
| with gr.Row(): | |
| csv_input = gr.Textbox( | |
| lines=14, | |
| label="Pipe-delimited input", | |
| placeholder=( | |
| "20264402 | O'Connor LLP | 2023/00700 | Permanent TSB PLC v Jeffrey Barrett | " | |
| "Attendance, Limerick Circuit Court County Registrar 6.3.26, adjourned | 200\n" | |
| "20264403 | Beauchamps LLP | 2023/00538 | Pepper Finance v Lynch | " | |
| "Attendance, Limerick Circuit Court County Registrar 6.3.26, adjourned | 200" | |
| ), | |
| ) | |
| with gr.Row(): | |
| run_button = gr.Button("Generate Fee Notes", variant="primary", size="lg") | |
| with gr.Row(): | |
| output_zip = gr.File(label="Download fee notes (.zip)") | |
| status_output = gr.Markdown(label="Status") | |
| run_button.click( | |
| fn=generate_batch_fee_notes, | |
| inputs=[csv_input], | |
| outputs=[output_zip, status_output], | |
| ) | |
| # ── Add New Solicitor section ── | |
| gr.Markdown("---") | |
| gr.Markdown("### Add New Solicitor") | |
| gr.Markdown("Add a firm that isn't in the directory yet. It will be available immediately for the next generation run.") | |
| with gr.Row(): | |
| with gr.Column(): | |
| new_firm_name = gr.Textbox(label="Firm Name", placeholder="e.g. MDM Solicitors LLP") | |
| new_firm_address = gr.Textbox(label="Address", placeholder="e.g. 16 Lavitt's Quay, Cork, T12 ED74") | |
| with gr.Column(): | |
| new_firm_domain = gr.Textbox(label="Domain (optional)", placeholder="e.g. mdmsolicitors.ie") | |
| new_firm_email = gr.Textbox(label="Email routing notes (optional)", placeholder="e.g. returns@mdmsolicitors.ie") | |
| add_button = gr.Button("Add Solicitor", size="sm") | |
| add_result = gr.Markdown() | |
| add_button.click( | |
| fn=add_solicitor, | |
| inputs=[new_firm_name, new_firm_address, new_firm_domain, new_firm_email], | |
| outputs=[add_result], | |
| ) | |
| # ── Current directory ── | |
| gr.Markdown("---") | |
| with gr.Accordion("View Current Solicitor Directory", open=False): | |
| firms_display = gr.Markdown(value=get_current_firms) | |
| refresh_btn = gr.Button("Refresh", size="sm") | |
| refresh_btn.click(fn=get_current_firms, outputs=[firms_display]) | |
| demo.launch(ssr_mode=False) | |