Teamtheo613 commited on
Commit
1629ced
·
verified ·
1 Parent(s): 1209e1c

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -483
app.py DELETED
@@ -1,483 +0,0 @@
1
- import os
2
- import re
3
- import subprocess
4
- import tempfile
5
- import zipfile
6
- import csv
7
- from datetime import datetime
8
- from io import StringIO
9
- from difflib import SequenceMatcher
10
-
11
- import gradio as gr
12
- from docx import Document
13
-
14
-
15
- TEMPLATE_PATH = "Fee-Note-Template.docx"
16
- ADDRESSES_PATH = "Solicitors-Addresses.txt"
17
-
18
-
19
- # ── Solicitor address loading & fuzzy matching ──────────────────────────────
20
-
21
- def load_solicitor_addresses():
22
- """
23
- Parse Solicitors-Addresses.txt → dict of {canonical_name: address}.
24
- Also builds a lookup index keyed by normalised name for fuzzy matching.
25
- """
26
- addresses = {}
27
-
28
- if not os.path.exists(ADDRESSES_PATH):
29
- return addresses
30
-
31
- with open(ADDRESSES_PATH, "r", encoding="utf-8") as f:
32
- content = f.read()
33
-
34
- blocks = content.strip().split("\n\n")
35
-
36
- for block in blocks:
37
- lines = block.strip().split("\n")
38
- if not lines:
39
- continue
40
-
41
- firm_name = lines[0].strip()
42
- address = ""
43
-
44
- for line in lines[1:]:
45
- if line.strip().startswith("Address:"):
46
- address = line.replace("Address:", "").strip()
47
- break
48
-
49
- if firm_name and address:
50
- addresses[firm_name] = address
51
-
52
- return addresses
53
-
54
-
55
- # Common legal entity suffixes to strip for matching purposes
56
- _LEGAL_SUFFIXES = re.compile(
57
- r"\b(llp|llc|ltd|limited|plc|dac|clg|lp|inc|incorporated|solicitors?)\b",
58
- re.IGNORECASE,
59
- )
60
-
61
-
62
- def normalise_firm_name(name):
63
- """
64
- Normalise a firm name for comparison:
65
- - lowercase
66
- - '&' ↔ 'and'
67
- - strip punctuation (dots, commas, apostrophes)
68
- - strip common legal suffixes (LLP, Ltd, Solicitors, etc.)
69
- - collapse whitespace
70
- """
71
- s = name.lower().strip()
72
- # Normalise ampersand ↔ 'and'
73
- s = s.replace(" & ", " and ").replace("&", " and ")
74
- # Strip common punctuation
75
- s = re.sub(r"[.,;:'\u2018\u2019\u201C\u201D\"()]+", "", s)
76
- # Strip legal suffixes (LLP, Ltd, Solicitors, etc.)
77
- s = _LEGAL_SUFFIXES.sub("", s)
78
- # Collapse whitespace
79
- s = re.sub(r"\s+", " ", s).strip()
80
- return s
81
-
82
-
83
- def find_solicitor(input_name, addresses_dict):
84
- """
85
- Try to match input_name against canonical solicitor names.
86
- Returns (canonical_name, address) or raises ValueError.
87
-
88
- Matching strategy (in order):
89
- 1. Exact match (case-insensitive)
90
- 2. Normalised exact match (& ↔ and, strip punctuation)
91
- 3. Substring containment (either direction)
92
- 4. Token overlap ≥ 70%
93
- 5. SequenceMatcher ratio ≥ 0.80
94
- """
95
- if not input_name:
96
- raise ValueError("Solicitor name is empty")
97
-
98
- # 1. Exact case-insensitive
99
- for canonical, addr in addresses_dict.items():
100
- if input_name.lower() == canonical.lower():
101
- return canonical, addr
102
-
103
- # 2. Normalised exact
104
- input_norm = normalise_firm_name(input_name)
105
- for canonical, addr in addresses_dict.items():
106
- if input_norm == normalise_firm_name(canonical):
107
- return canonical, addr
108
-
109
- # 3. Substring containment
110
- for canonical, addr in addresses_dict.items():
111
- canon_norm = normalise_firm_name(canonical)
112
- if input_norm in canon_norm or canon_norm in input_norm:
113
- return canonical, addr
114
-
115
- # 4. Token overlap
116
- input_tokens = set(input_norm.split())
117
- best_overlap = 0
118
- best_match = None
119
- for canonical, addr in addresses_dict.items():
120
- canon_tokens = set(normalise_firm_name(canonical).split())
121
- if not canon_tokens:
122
- continue
123
- overlap = len(input_tokens & canon_tokens) / max(len(input_tokens), len(canon_tokens))
124
- if overlap > best_overlap:
125
- best_overlap = overlap
126
- best_match = (canonical, addr)
127
-
128
- if best_overlap >= 0.70 and best_match:
129
- return best_match
130
-
131
- # 5. SequenceMatcher
132
- best_ratio = 0
133
- best_match = None
134
- for canonical, addr in addresses_dict.items():
135
- ratio = SequenceMatcher(None, input_norm, normalise_firm_name(canonical)).ratio()
136
- if ratio > best_ratio:
137
- best_ratio = ratio
138
- best_match = (canonical, addr)
139
-
140
- if best_ratio >= 0.80 and best_match:
141
- return best_match
142
-
143
- # No match — build helpful error
144
- # Show top 3 closest matches
145
- scored = []
146
- for canonical in addresses_dict:
147
- ratio = SequenceMatcher(None, input_norm, normalise_firm_name(canonical)).ratio()
148
- scored.append((canonical, ratio))
149
- scored.sort(key=lambda x: x[1], reverse=True)
150
- suggestions = [f" • {name} ({ratio:.0%})" for name, ratio in scored[:3]]
151
-
152
- raise ValueError(
153
- f"Solicitor '{input_name}' not found.\n"
154
- f"Closest matches:\n" + "\n".join(suggestions) + "\n\n"
155
- f"You can add this firm using the 'Add New Solicitor' section below."
156
- )
157
-
158
-
159
- # ── Cross-run text replacement ──────────────────────────────────────────────
160
-
161
- def replace_in_paragraph(paragraph, old_text, new_text):
162
- """
163
- Replace text across runs in a paragraph while preserving formatting
164
- of the first run. Handles placeholders split across multiple runs.
165
- """
166
- full_text = paragraph.text
167
- if old_text not in full_text:
168
- return False
169
-
170
- new_full = full_text.replace(old_text, new_text)
171
-
172
- runs = paragraph.runs
173
- if not runs:
174
- return False
175
-
176
- # Preserve first run's formatting, put all text there, clear the rest
177
- runs[0].text = new_full
178
- for r in runs[1:]:
179
- r.text = ""
180
- return True
181
-
182
-
183
- def apply_replacements(doc, replacements):
184
- """Apply all replacements across paragraphs and table cells."""
185
- for old, new in replacements.items():
186
- # Top-level paragraphs
187
- for p in doc.paragraphs:
188
- replace_in_paragraph(p, old, new)
189
- # Table cells
190
- for table in doc.tables:
191
- for row in table.rows:
192
- for cell in row.cells:
193
- for p in cell.paragraphs:
194
- replace_in_paragraph(p, old, new)
195
-
196
-
197
- # ── PDF conversion ──────────────────────────────────────────────────────────
198
-
199
- def convert_docx_to_pdf(docx_path, output_dir):
200
- """
201
- Convert a .docx to .pdf using LibreOffice.
202
- Returns the path to the generated PDF.
203
- """
204
- try:
205
- subprocess.run(
206
- [
207
- "libreoffice", "--headless", "--norestore",
208
- "--convert-to", "pdf",
209
- "--outdir", output_dir,
210
- docx_path,
211
- ],
212
- capture_output=True,
213
- timeout=30,
214
- check=True,
215
- )
216
- pdf_name = os.path.splitext(os.path.basename(docx_path))[0] + ".pdf"
217
- pdf_path = os.path.join(output_dir, pdf_name)
218
- if os.path.exists(pdf_path):
219
- return pdf_path
220
- except Exception:
221
- pass
222
- return None
223
-
224
-
225
- # ── Date formatting ─────────────────────────────────────────────────────────
226
-
227
- def format_date_for_fee_note(dt=None):
228
- """
229
- Format date as '6 March 2026' (no leading zero on day).
230
- """
231
- if dt is None:
232
- dt = datetime.today()
233
- return f"{dt.day} {dt.strftime('%B')} {dt.year}"
234
-
235
-
236
- # ── Main generation logic ───────────────────────────────────────────────────
237
-
238
- def generate_batch_fee_notes(csv_text):
239
- """
240
- Process pipe-delimited input and generate fee note DOCX + PDF files.
241
-
242
- Required columns:
243
- filename | solicitor_name | case_record | case_name | description | net_amount
244
- """
245
- addresses_lookup = load_solicitor_addresses()
246
-
247
- if not csv_text.strip():
248
- raise gr.Error("Input is empty. Please paste at least one row of data.")
249
-
250
- # Check if user included a header row
251
- lines = csv_text.strip().split("\n")
252
- first_line_lower = lines[0].lower()
253
- has_header = "filename" in first_line_lower and "solicitor" in first_line_lower
254
-
255
- if not has_header:
256
- # Prepend the header row automatically
257
- csv_text = "filename | solicitor_name | case_record | case_name | description | net_amount\n" + csv_text
258
-
259
- csv_reader = csv.DictReader(
260
- StringIO(csv_text),
261
- delimiter="|",
262
- quotechar='"',
263
- )
264
-
265
- if not csv_reader.fieldnames:
266
- raise gr.Error("Could not parse input. Check the format and try again.")
267
-
268
- csv_reader.fieldnames = [h.strip() for h in csv_reader.fieldnames]
269
-
270
- required_cols = {"filename", "solicitor_name", "case_record", "case_name", "description", "net_amount"}
271
- provided_cols = set(csv_reader.fieldnames)
272
-
273
- if not required_cols.issubset(provided_cols):
274
- missing = required_cols - provided_cols
275
- raise gr.Error(
276
- f"Missing required columns: {', '.join(missing)}\n"
277
- f"Required: filename | solicitor_name | case_record | case_name | description | net_amount"
278
- )
279
-
280
- if not os.path.exists(TEMPLATE_PATH):
281
- raise gr.Error(
282
- f"Template '{TEMPLATE_PATH}' not found. "
283
- f"Please upload Fee-Note-Template.docx to the Space."
284
- )
285
-
286
- tmpdir = tempfile.mkdtemp()
287
- output_files = []
288
- today_str = format_date_for_fee_note()
289
- errors = []
290
-
291
- rows = list(csv_reader)
292
- if not rows:
293
- raise gr.Error("No data rows found. Please check your input.")
294
-
295
- for row_num, row in enumerate(rows, start=1):
296
- try:
297
- row = {k: (v.strip() if v else "") for k, v in row.items()}
298
-
299
- filename = row["filename"].strip()
300
- if not filename:
301
- errors.append(f"Row {row_num}: filename is empty, skipped.")
302
- continue
303
-
304
- fee_note_number = filename
305
- docx_name = filename if filename.endswith(".docx") else filename + ".docx"
306
-
307
- solicitor_name = row["solicitor_name"].strip()
308
- canonical_name, address = find_solicitor(solicitor_name, addresses_lookup)
309
-
310
- # Parse net amount
311
- raw_net = row["net_amount"].replace("€", "").replace(",", "").strip()
312
- if not raw_net:
313
- errors.append(f"Row {row_num}: net_amount is empty, skipped.")
314
- continue
315
-
316
- net_value = float(raw_net)
317
- vat_value = round(net_value * 0.23, 2)
318
- gross_value = round(net_value + vat_value, 2)
319
-
320
- net_str = f"€{net_value:,.2f}"
321
- vat_str = f"€{vat_value:,.2f}"
322
- gross_str = f"€{gross_value:,.2f}"
323
-
324
- # Load template and apply replacements
325
- doc = Document(TEMPLATE_PATH)
326
-
327
- replacements = {
328
- "[Insert Todays Date Here]": today_str,
329
- "[Insert Solicitor Here]": canonical_name,
330
- "[Insert Address Here]": address,
331
- "[Insert Case Record Number]": row["case_record"].strip(),
332
- "[Insert Case Name]": row["case_name"].strip(),
333
- "[Insert Description]": row["description"].strip(),
334
- "[Insert Net Amount]": net_str,
335
- "[Insert VAT at .23% of Net Amount]": vat_str,
336
- "[Insert Gross Here]": gross_str,
337
- "[Insert Number Here]": fee_note_number,
338
- }
339
-
340
- apply_replacements(doc, replacements)
341
-
342
- docx_path = os.path.join(tmpdir, docx_name)
343
- doc.save(docx_path)
344
- output_files.append((docx_name, docx_path))
345
-
346
- # Convert to PDF
347
- pdf_path = convert_docx_to_pdf(docx_path, tmpdir)
348
- if pdf_path:
349
- pdf_name = os.path.splitext(docx_name)[0] + ".pdf"
350
- output_files.append((pdf_name, pdf_path))
351
-
352
- except ValueError as e:
353
- errors.append(f"Row {row_num}: {str(e)}")
354
- except Exception as e:
355
- errors.append(f"Row {row_num}: unexpected error — {str(e)}")
356
-
357
- if not output_files:
358
- error_msg = "No fee notes were generated.\n\n" + "\n".join(errors) if errors else "No fee notes were generated."
359
- raise gr.Error(error_msg)
360
-
361
- # Create zip
362
- zip_path = os.path.join(tmpdir, "Fee-Notes.zip")
363
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
364
- for display_name, file_path in output_files:
365
- zf.write(file_path, arcname=display_name)
366
-
367
- status_parts = [f"Generated {len([f for f in output_files if f[0].endswith('.docx')])} fee notes (DOCX + PDF)."]
368
- if errors:
369
- status_parts.append("\n**Warnings:**\n" + "\n".join(f"- {e}" for e in errors))
370
-
371
- return zip_path, "\n".join(status_parts)
372
-
373
-
374
- # ── Add new solicitor ───────────────────────────────────────────────────────
375
-
376
- def add_solicitor(firm_name, firm_address, firm_domain, firm_email_notes):
377
- """Append a new solicitor to Solicitors-Addresses.txt."""
378
- if not firm_name.strip():
379
- raise gr.Error("Firm name is required.")
380
- if not firm_address.strip():
381
- raise gr.Error("Address is required.")
382
-
383
- block = f"\n\n{firm_name.strip()}\nAddress: {firm_address.strip()}"
384
- if firm_domain.strip():
385
- block += f"\nDomain: {firm_domain.strip()}"
386
- if firm_email_notes.strip():
387
- block += f"\nEmail return always: {firm_email_notes.strip()}"
388
-
389
- with open(ADDRESSES_PATH, "a", encoding="utf-8") as f:
390
- f.write(block + "\n")
391
-
392
- # Reload and return updated list
393
- addresses = load_solicitor_addresses()
394
- firms_list = "\n".join(f"• {name} — {addr}" for name, addr in sorted(addresses.items()))
395
- return f"Added **{firm_name.strip()}** successfully.\n\n**Current firms ({len(addresses)}):**\n{firms_list}"
396
-
397
-
398
- def get_current_firms():
399
- """Return formatted list of current firms."""
400
- addresses = load_solicitor_addresses()
401
- if not addresses:
402
- return "No firms loaded. Check that Solicitors-Addresses.txt exists in the Space."
403
- return "\n".join(f"• **{name}** — {addr}" for name, addr in sorted(addresses.items()))
404
-
405
-
406
- # ── Gradio UI ───────────────────────────────────────────────────────────────
407
-
408
- with gr.Blocks(title="Fee Note Generator", theme=gr.themes.Soft()) as demo:
409
- gr.Markdown(
410
- """
411
- # Fee Note Generator
412
-
413
- Paste pipe-delimited fee note data below. Each row generates a DOCX and PDF fee note from your template.
414
-
415
- **Format** (header row is optional — it will be added automatically if missing):
416
-
417
- ```
418
- filename | solicitor_name | case_record | case_name | description | net_amount
419
- ```
420
-
421
- - **filename**: becomes the file name and fee note number (e.g. `20264402` → `20264402.docx` / `.pdf`)
422
- - **solicitor_name**: matched against the solicitor directory (fuzzy matching handles minor variations)
423
- - **net_amount**: numeric, e.g. `200` or `1000.00` — VAT (23%) and gross are calculated automatically
424
- - **Date**: today's date is inserted automatically
425
- """
426
- )
427
-
428
- with gr.Row():
429
- csv_input = gr.Textbox(
430
- lines=14,
431
- label="Pipe-delimited input",
432
- placeholder=(
433
- "20264402 | O'Connor LLP | 2023/00700 | Permanent TSB PLC v Jeffrey Barrett | "
434
- "Attendance, Limerick Circuit Court County Registrar 6.3.26, adjourned | 200\n"
435
- "20264403 | Beauchamps LLP | 2023/00538 | Pepper Finance v Lynch | "
436
- "Attendance, Limerick Circuit Court County Registrar 6.3.26, adjourned | 200"
437
- ),
438
- )
439
-
440
- with gr.Row():
441
- run_button = gr.Button("Generate Fee Notes", variant="primary", size="lg")
442
-
443
- with gr.Row():
444
- output_zip = gr.File(label="Download fee notes (.zip)")
445
- status_output = gr.Markdown(label="Status")
446
-
447
- run_button.click(
448
- fn=generate_batch_fee_notes,
449
- inputs=[csv_input],
450
- outputs=[output_zip, status_output],
451
- )
452
-
453
- # ── Add New Solicitor section ──
454
- gr.Markdown("---")
455
- gr.Markdown("### Add New Solicitor")
456
- gr.Markdown("Add a firm that isn't in the directory yet. It will be available immediately for the next generation run.")
457
-
458
- with gr.Row():
459
- with gr.Column():
460
- new_firm_name = gr.Textbox(label="Firm Name", placeholder="e.g. MDM Solicitors LLP")
461
- new_firm_address = gr.Textbox(label="Address", placeholder="e.g. 16 Lavitt's Quay, Cork, T12 ED74")
462
- with gr.Column():
463
- new_firm_domain = gr.Textbox(label="Domain (optional)", placeholder="e.g. mdmsolicitors.ie")
464
- new_firm_email = gr.Textbox(label="Email routing notes (optional)", placeholder="e.g. returns@mdmsolicitors.ie")
465
-
466
- add_button = gr.Button("Add Solicitor", size="sm")
467
- add_result = gr.Markdown()
468
-
469
- add_button.click(
470
- fn=add_solicitor,
471
- inputs=[new_firm_name, new_firm_address, new_firm_domain, new_firm_email],
472
- outputs=[add_result],
473
- )
474
-
475
- # ── Current directory ──
476
- gr.Markdown("---")
477
- with gr.Accordion("View Current Solicitor Directory", open=False):
478
- firms_display = gr.Markdown(value=get_current_firms)
479
- refresh_btn = gr.Button("Refresh", size="sm")
480
- refresh_btn.click(fn=get_current_firms, outputs=[firms_display])
481
-
482
-
483
- demo.launch()