"""The Seamstresses - upload every version of your documents (any type), get back ONE sourced case summary
and ONE clean, deduplicated master document. Runs on free CPU, no paid APIs. Reading: intake.py (OCR and
speech-to-text run locally); merge engine: stitch.py; case summary: brief.py; Word/PDF: report_export.py."""
import hashlib, os, re, tempfile
from datetime import datetime
import gradio as gr
import json
import autopoc
import brief
import intake
import poc
import report_export
import stitch
def _paths(uploads):
if uploads is None:
return []
if not isinstance(uploads, (list, tuple)):
uploads = [uploads]
out = []
for f in uploads:
if f is None:
continue
p = (f.get('path') or f.get('name')) if isinstance(f, dict) else (f.name if hasattr(f, 'name') else str(f))
if p:
out.append(p)
return out
def _safe(name):
return re.sub(r'[^A-Za-z0-9 _-]+', '', name).strip().replace(' ', '_')[:60] or 'Merged_Document'
def stitch_documents(uploads, title='', pasted=''):
files = _paths(uploads)
if not files and not (pasted or '').strip():
return 'Please upload at least one document.', None, None, None, ''
docs, problems = [], []
for f in files:
try:
paras = stitch.extract(f)
except Exception as e:
problems.append(f'{os.path.basename(f)}: could not be read ({e})'); continue
if not paras:
problems.append(f'{os.path.basename(f)}: no text found (scanned image PDF?)'); continue
docs.append((os.path.basename(f), paras))
# optional pasted text: several versions can be separated by a line containing only ===
for n, chunk in enumerate(re.split(r'\n\s*={3,}\s*\n', pasted or ''), 1):
if chunk.strip():
docs.append((f'pasted text {n}', stitch.extract_text(chunk)))
if not docs:
return 'None of the files contained readable text.\n' + '\n'.join(problems), None, None, None, ''
title = (title or '').strip()
blocks, st = stitch.merge(docs)
text = stitch.to_text(title, blocks)
outdir = tempfile.mkdtemp()
base = os.path.join(outdir, _safe(title) if title else 'Merged_Document')
docx_path = stitch.export_docx(title, blocks, base + '.docx')
pdf_path = stitch.export_pdf(title, blocks, base + '.pdf')
with open(base + '.txt', 'w', encoding='utf-8') as fh:
fh.write(text)
note = (f"{st['files']} file(s) stitched into one document. "
f"Words in: {st['words_in']:,} -> out: {st['words_out']:,}. "
f"Repeated paragraphs merged: {st['dup_paras']} "
f"(unique details folded in from the weaker copy: {st['folded']}). "
f"Repeated sentences removed: {st['dup_sents']}. Repeated headings/short lines removed: {st['dup_heads']}. "
f"Backbone (fullest upload, its order is kept): {st['backbone']}.")
if st.get('fallback'):
note += (f"\n\n**Safety check:** the full merge did not pass ({st['fallback']}), so you have been given "
f"the fullest upload ({st['backbone']}) with only its own repeated paragraphs removed.")
if problems:
note += '\nSkipped: ' + '; '.join(problems)
return text, docx_path, pdf_path, base + '.txt', note
# ------------------------------------------------------------------ Particulars of Claim (separate feature)
POC_FIELDS = ['court', 'court_detail', 'claim_no', 'claimant_name', 'claimant_pronoun', 'claimant_is_company',
'claimant_capacity', 'dob', 'address_for_service', 'signed_by', 'defendants', 'defined_terms',
'goods_term', 'contract_how', 'contract_ref', 'contract_date', 'contract_obligation', 'price',
'paid_date', 'paid_how', 'delivery_date', 'express_terms', 'facts', 'causes', 'contract_breaches',
'description', 'description_mismatch', 'purpose', 'purpose_how', 'defects', 'defect_date',
'rejection_date', 'rejection_how', 'failings', 'service_failings', 'duty_scope', 'incident',
'cpa_role', 'representations', 'falsity', 'knowledge', 'impairment', 'effects', 'service', 'arising',
'unfavourable', 'pcp', 'disadvantage', 'adjustments', 'harassment_conduct', 'personal_data',
'gdpr_breaches', 'losses', 'injuries', 'medical_report', 'aggravated', 'injunction',
'interest_principal', 'interest_rate', 'interest_from', 'interest_to', 'value',
'against', 'contract_defendant', 'credit_card', 'psr_transactions', 'psr_defect_how', 'statutory_duty',
'enrich_amount', 'enrich_how', 'unjust_factor', 'unjust_basis', 'rescission_return']
def _poc_result(data):
out = tempfile.mkdtemp(prefix='poc_')
r = poc.draft(data, out)
n = len(r['confirm'])
note = (f"**Draft ready.** Items marked [CONFIRM: ...] need your input: **{n}**. "
"The drafting notes file lists them, the source of every fact paragraph and any warnings "
"(court, time limits, attachments). Built from your input only, using fixed templates. No AI and no paid service.")
if r['stray'] or r['bad_cases']:
note += f"\n\n**Validation flagged:** {', '.join(r['stray'] + r['bad_cases'])}"
return r['text'], r['docx'], r['pdf'], r['txt'], r['notes_file'], note
def draft_poc(*vals):
data = dict(zip(POC_FIELDS, vals))
data['causes'] = list(data.get('causes') or [])
data['claimant_is_company'] = 'yes' if data.get('claimant_is_company') in (True, 'yes', 'Yes', 'true') else 'no'
data = {k: ('' if v is None else v) for k, v in data.items()}
return _poc_result(data)
def draft_poc_json(text):
data = json.loads(text or '{}')
return _poc_result(data)
def extract_poc_facts(uploads, pasted, existing):
rows = poc.extract_facts(_paths(uploads), pasted or '')
if not rows:
return existing or '', 'No dated sentences were found.'
n = len([r for r in rows.split('\n') if r and not r.startswith('#')])
merged = ((existing or '').rstrip() + '\n' + rows).strip()
return merged, (f'Added {n} dated sentence(s), copied word for word. Review each line: delete what is not a '
'material fact, and replace the file name in the source column with your document reference.')
def create_particulars(uploads, links='', pasted='', picks_json='{}'):
"""One button: read the same uploads as the Merge tab, merge/deduplicate them, detect parties, dates, sums,
references and the claims the documents support, and draft Particulars of Claim. Gaps stay [CONFIRM: ...]."""
docs, problems = _gather(uploads, links, pasted)
if not docs:
return ('**Nothing to work from yet.** Upload your documents on the **Merge documents** tab (or paste text there), '
'then come back and press the button.' + ('\n\n**Problems:** ' + '; '.join(problems) if problems else ''),
None, None, '')
try:
picks = {str(k): int(v) for k, v in json.loads(picks_json or '{}').items()}
except Exception: # noqa
picks = {}
R = brief.build(docs, picks, '', keep_new=not picks)
r, sections = autopoc.create(docs, R, tempfile.mkdtemp(prefix='poc_'))
md = [f"### ✅ Draft ready from {len(docs)} document(s)",
'The same check list is the first page of the Word and PDF - delete it before filing.']
for head, items in sections:
if items:
md.append(f'**{head}**\n' + '\n'.join(f'- {x}' for x in items))
if r['stray'] or r['bad_cases']:
md.append('**Validation flagged:** ' + ', '.join(r['stray'] + r['bad_cases']))
if problems:
md.append('**Skipped:** ' + '; '.join(problems))
return '\n\n'.join(md), r['docx'], r['pdf'], r['text']
# ------------------------------------------------------------------ new flow: upload -> run -> review -> build -> download
MAX_LINKS = 10
def _gather(uploads, links='', pasted=''):
"""Read every upload / link / pasted chunk -> (docs with ids D1.., problems)."""
docs, problems = [], []
def take(got):
for d in got:
if d['paras']:
docs.append(dict(d))
else:
problems.append(f"{d['name']}: no readable text found" + (' (' + '; '.join(d['warnings']) + ')' if d.get('warnings') else ''))
for f in _paths(uploads):
try:
take(intake.load(f))
except Exception as e: # noqa
problems.append(f'{os.path.basename(f)}: could not be read ({e})')
for ln in [x.strip() for x in re.split(r'[\s,]+', links or '') if x.strip()][:MAX_LINKS]:
if not re.match(r'https://', ln):
problems.append(f'{ln[:60]}: only https:// links can be fetched'); continue
try:
take(intake.load(intake.fetch_link(ln)))
except Exception as e: # noqa
problems.append(f'{ln[:60]}: could not be fetched ({e})')
for n, chunk in enumerate(re.split(r'\n\s*={3,}\s*\n', pasted or ''), 1):
if chunk.strip():
docs.append({'name': f'pasted text {n}', 'paras': stitch.extract_text(chunk), 'pages': [],
'sha256': hashlib.sha256(chunk.encode('utf-8')).hexdigest(), 'size': len(chunk.encode('utf-8')),
'kind': 'text', 'extractor': 'pasted text', 'warnings': []})
for i, d in enumerate(docs, 1):
d['id'] = f'D{i}'
return docs, problems
def _files(R, mode):
outdir = tempfile.mkdtemp(prefix='seam_')
base = os.path.join(outdir, _safe(R['headline'])[:50] or 'Case_summary')
report_export.export_docx(R, base + '.docx')
report_export.export_pdf(R, base + '.pdf')
with open(base + '.txt', 'w', encoding='utf-8') as fh:
fh.write(R['text'])
with open(base + '_audit.json', 'w', encoding='utf-8') as fh:
fh.write(brief.audit_json(R, mode))
return base + '.docx', base + '.pdf', base + '.txt', base + '_audit.json'
def _master_text(R):
t = R['text']
i = t.find('PART 2 - MASTER DOCUMENT')
return t[i:] if i >= 0 else t
def _status(R, docs, problems, mode):
d = R['dedup']
msg = (f"### ✅ Done - {len(docs)} document(s) → one case summary + master document\n"
f"{'⚡ Strongest version of each duplicate chosen for you' if mode == 'auto' else '✋ Built with your choices'}. "
f"{d['copies_removed']} duplicate paragraph(s) merged, {len(R['conflicts'])} contradiction(s) flagged, "
f"{len(R['chronology'])} chronology entries. SHA-256 seal: `{R['seal'][:16]}…`")
if problems:
msg += '\n\n**Skipped:** ' + '; '.join(problems)
return msg
def _empty_result(msg):
return msg, '', '', None, None, None, None
def _run_build(uploads, links, pasted, title, picks, mode):
docs, problems = _gather(uploads, links, pasted)
if not docs:
return _empty_result('**Nothing to read yet.** Upload at least one document, paste text or add a link.'
+ ('\n\n**Problems:** ' + '; '.join(problems) if problems else ''))
R = brief.build(docs, picks, (title or '').strip(), keep_new=(mode == 'auto'))
return (_status(R, docs, problems, mode), brief.to_markdown(R), _master_text(R)) + _files(R, mode)
def choose_for_me(uploads, links, pasted, title):
"""Auto-pick the strongest version of every duplicate and build straight away."""
return _run_build(uploads, links, pasted, title, {}, 'auto')
def build_with_picks(uploads, links, pasted, title, picks_json):
"""Build using the user's choice per duplicate group: picks_json = {"group id": version index (0-based)}."""
try:
picks = {str(k): int(v) for k, v in json.loads(picks_json or '{}').items()}
except Exception: # noqa
picks = {}
return _run_build(uploads, links, pasted, title, picks, 'user')
def let_me_choose(uploads, links, pasted):
"""Find the duplicate groups that are worded differently, with the best version pre-selected."""
docs, problems = _gather(uploads, links, pasted)
if not docs:
return ('**Nothing to read yet.** Upload at least one document, paste text or add a link.', [], '{}', gr.update(visible=False))
backbone = max(range(len(docs)), key=lambda k: stitch._quality(docs[k]['paras']))
groups = brief.dup_groups(docs, backbone)
loc = brief.Locator(docs)
view = []
for g in groups:
if len(g['versions']) < 2:
continue
vs = []
for v in g['versions']:
rs = loc.find(v['text'], limit=4) or [{'doc': docs[di]['id'], 'page': None, 'para': pi + 1} for di, pi in v['occ']]
names = sorted({docs[di]['name'] for di, _ in v['occ']})
vs.append({'text': brief.clean(v['text']), 'refs': brief.refs(rs, 4), 'files': ', '.join(names)[:120],
'words': len(v['text'].split())})
view.append({'id': g['id'], 'best': g['best'], 'differ': g['figures_differ'], 'versions': vs})
picks = {str(g['id']): g['best'] for g in view}
same = len(groups) - len(view)
msg = (f"### ✋ Your turn - {len(view)} duplicate group(s) worded differently\n"
f"The strongest version of each is already selected (⭐). Change any you like, then press **Build my document**. "
f"{same} exact copies will be merged automatically.")
if not view:
msg = (f"### No differently-worded duplicates found\n{same} exact copies will be merged automatically. "
"Press **Build my document**.")
if problems:
msg += '\n\n**Skipped:** ' + '; '.join(problems)
return msg, view, json.dumps(picks), gr.update(visible=True)
def accept_all_best(groups):
return json.dumps({str(g['id']): g['best'] for g in (groups or [])})
def _set_pick(cur, gid, val):
try:
d = json.loads(cur or '{}')
except Exception: # noqa
d = {}
d[str(gid)] = int(val)
return json.dumps(d)
BLUE_CSS = """
.big-btn button, button.big-btn {min-height: 76px !important; font-size: 1.25rem !important; font-weight: 700 !important;}
.ver {border: 1px solid #c7d7f2; border-radius: 10px; padding: 8px 12px; background: #f6f9ff;}
.ver.best {border: 2px solid #1f5fbf; background: #eaf2ff;}
.ver .refs {color: #1f4e99; font-size: .85rem;}
"""
with gr.Blocks(title='The Seamstresses') as demo:
groups_state = gr.State([])
with gr.Tab('Merge documents'):
gr.Markdown('# 🧵 The Seamstresses\n'
'**Upload → Run → Review → Download.** Drop in every version of your documents - any type. '
'You get back **one case summary** (parties, chronology, contradictions, laws cited, harm, requests) '
'and **one master document** with the repeats taken out. Every line is quoted word for word with '
'its source (document, page or time, paragraph). Nothing is invented or reworded.')
files_new = gr.File(label='1. Your documents - PDF (also scanned), Word, text, email (.eml), spreadsheets, photos, audio or video',
file_count='multiple', type='filepath')
with gr.Accordion('Optional: Google Docs / Drive links, pasted text, a title', open=False):
links_in = gr.Textbox(value='', label='Google Docs / Sheets / Drive links (shared as "Anyone with the link"), one per line', lines=2)
paste_new = gr.Textbox(value='', label='Pasted text. Separate several versions with a line containing only ===', lines=5, max_lines=40)
title_new = gr.Textbox(value='', label='Title (optional - otherwise taken from the documents)')
gr.Markdown('### 2. How should duplicates be handled?')
with gr.Row(equal_height=True):
auto_btn = gr.Button('⚡ Choose for me', variant='primary', size='lg', elem_classes='big-btn')
pick_btn = gr.Button('✋ Let me choose', variant='secondary', size='lg', elem_classes='big-btn')
gr.Markdown('⚡ picks the strongest version of every duplicate and builds straight away. '
'✋ shows the duplicates side by side with the strongest already selected, so you can change any.')
status_md = gr.Markdown()
picks_box = gr.Textbox(value='{}', visible=False, label='picks_json')
with gr.Column(visible=False) as review_col:
groups_json = gr.JSON(visible=False)
@gr.render(inputs=[groups_state])
def _review(groups):
for g in groups or []:
with gr.Group():
gr.Markdown(f"#### Group {g['id']}" + (' ⚠️ these versions differ on dates, figures or yes/no - check carefully' if g['differ'] else ''))
with gr.Row(equal_height=False):
for vi, v in enumerate(g['versions']):
with gr.Column(min_width=240, elem_classes='ver best' if vi == g['best'] else 'ver'):
gr.Markdown(f"**Version {vi + 1}**{' ⭐ strongest' if vi == g['best'] else ''} \n"
f"{v['refs']} · {v['files']} · {v['words']} words")
gr.Markdown('> ' + v['text'].replace('\n', ' '))
r = gr.Radio(choices=[(f"Keep version {i + 1}" + (' ⭐' if i == g['best'] else ''), i) for i in range(len(g['versions']))],
value=g['best'], label=f"Group {g['id']} - which version goes in the document?", interactive=True)
r.change(lambda val, cur, gid=g['id']: _set_pick(cur, gid, val), [r, picks_box], [picks_box], api_visibility='private')
with gr.Row():
best_btn = gr.Button('✅ Accept all best', variant='secondary', size='lg')
build_btn = gr.Button('🧵 Build my document', variant='primary', size='lg', elem_classes='big-btn')
gr.Markdown('### 3. Download')
with gr.Row():
docx_new = gr.File(label='Word (.docx)', interactive=False)
pdf_new = gr.File(label='PDF', interactive=False)
txt_new = gr.File(label='Text (.txt)', interactive=False)
json_new = gr.File(label='Audit (.json)', interactive=False)
preview_md = gr.Markdown()
master_out = gr.Textbox(label='Master document (merged, wording exactly as uploaded)', lines=20, max_lines=2000)
outs = [status_md, preview_md, master_out, docx_new, pdf_new, txt_new, json_new]
auto_btn.click(lambda: gr.update(visible=False), None, [review_col], api_visibility='private').then(
choose_for_me, [files_new, links_in, paste_new, title_new], outs, api_name='choose_for_me')
pick_btn.click(let_me_choose, [files_new, links_in, paste_new], [status_md, groups_json, picks_box, review_col],
api_name='let_me_choose').then(lambda g: g, [groups_json], [groups_state], api_visibility='private')
best_btn.click(accept_all_best, [groups_state], [picks_box], api_visibility='private').then(
build_with_picks, [files_new, links_in, paste_new, title_new, picks_box], outs, api_name='accept_all_best', api_visibility='private')
build_btn.click(build_with_picks, [files_new, links_in, paste_new, title_new, picks_box], outs, api_name='build_with_picks')
# legacy endpoint kept byte-for-byte for existing scripts (/stitch); not shown on screen
with gr.Column(visible=False):
files_in = gr.File(file_count='multiple', type='filepath')
title_in = gr.Textbox(value='')
paste_in = gr.Textbox(value='')
btn = gr.Button('legacy stitch')
docx_out = gr.File(); pdf_out = gr.File(); txt_out = gr.File()
note_out = gr.Markdown()
doc_out = gr.Textbox()
btn.click(stitch_documents, inputs=[files_in, title_in, paste_in],
outputs=[doc_out, docx_out, pdf_out, txt_out, note_out], api_name='stitch')
with gr.Tab('Particulars of Claim'):
gr.Markdown('## Particulars of Claim\n'
'Uses the documents you uploaded on the **Merge documents** tab - nothing to type. Repeats are merged, '
'then the parties, dates, sums, references and the claims your documents support are picked out and '
'drafted in court layout. Names from a court document (claim form, order) are used if there is one. '
'Anything not found is left as **[CONFIRM: ...]**. Facts come only from your documents, law only from '
'a fixed list of statutes, and no case law is cited.')
c_btn = gr.Button('Create Particulars of Claim', variant='primary', size='lg', elem_classes='big-btn')
c_status = gr.Markdown()
with gr.Row():
c_docx = gr.File(label='Word (.docx)', interactive=False)
c_pdf = gr.File(label='PDF', interactive=False)
c_text = gr.Textbox(label='Preview', lines=24, max_lines=3000)
c_btn.click(create_particulars, [files_new, links_in, paste_new, picks_box], [c_status, c_docx, c_pdf, c_text],
api_name='create_particulars')
with gr.Column(visible=False): # previous manual form, kept so /draft_poc, /draft_poc_json, /extract_facts still work
gr.Markdown('## Particulars of Claim drafter (England & Wales)\n'
'Fill in what you know. Every fact pleaded comes **only** from what you type or extract. Anything '
'essential that is missing is marked **[CONFIRM: ...]**. The law cited comes from a fixed list of '
'statutory provisions, and no case law is ever cited. Output: Word, PDF and text in court layout, '
'plus a separate drafting notes file (not for service). Lines with several parts use **|** between them.')
with gr.Accordion('1. Court and parties', open=True):
with gr.Row():
p_court = gr.Dropdown(['County Court', 'High Court'], value='County Court', label='Court')
p_court_detail = gr.Textbox(label='Hearing centre (County Court) or list (High Court)', placeholder='e.g. Central London')
p_claim_no = gr.Textbox(label='Claim number', placeholder='leave blank if not yet issued')
with gr.Row():
p_cl_name = gr.Textbox(label='Claimant full name')
p_cl_pron = gr.Dropdown(['he', 'she', 'they', 'it'], value='they', label='Claimant pronoun')
p_cl_co = gr.Checkbox(label='Claimant is a company', value=False)
p_cl_cap = gr.Textbox(label='Claimant status', placeholder='e.g. an individual who bought the goods for his own personal use')
with gr.Row():
p_dob = gr.Textbox(label='Claimant date of birth (personal injury claims only)', placeholder='dd/mm/yyyy')
p_addr = gr.Textbox(label='Address for service')
p_signed = gr.Textbox(label='Signed by (leave blank if the Claimant is in person)')
p_defs = gr.Textbox(label='Defendant(s), one per line: name | description | company no. | other names used in your facts', lines=2,
placeholder='Acme Widgets Ltd | a company which sells widgets online | 01234567 | Acme | it\nJohn Smith | a director of the First Defendant | | Mr Smith | he')
p_against = gr.Textbox(label='Who each claim is against (only needed if there is more than one Defendant), one per line: claim | defendant numbers | jointly and severally / several | capacities',
lines=3, placeholder='deceit | 2, 1 | jointly and severally | 2 personally; 1 vicariously for 2\ns75 | 3\n(capacities: "N personally", "M vicariously for N", "N as agent of M")')
p_terms = gr.Textbox(label='Defined terms, one per line: short name | full description | other words you use for it', lines=2,
placeholder='Scooter | a Voltrider V9 Pro electric scooter, serial no. X | scooter, e-scooter')
with gr.Accordion('2. The contract (if any)', open=False):
with gr.Row():
p_goods = gr.Textbox(label='Short name of the goods/service (one of your defined terms)')
p_c_how = gr.Textbox(label='How the contract was made', placeholder="e.g. an online order placed through the Defendant's website")
p_c_ref = gr.Textbox(label='Order or contract reference')
with gr.Row():
p_c_date = gr.Textbox(label='Contract date')
p_c_obl = gr.Textbox(label='What the Defendant agreed to do (optional)', placeholder='e.g. provide the Account to the Claimant on the Terms')
p_price = gr.Textbox(label='Price (£)')
with gr.Row():
p_paid_d = gr.Textbox(label='Date paid')
p_paid_h = gr.Textbox(label='How paid', placeholder='e.g. Visa debit card')
p_deliv = gr.Textbox(label='Delivery date')
p_express = gr.Textbox(label='Express terms, one per line: where found | what it says', lines=2)
p_cdef = gr.Textbox(label='Which Defendant is the other party to the contract (number)', value='1')
with gr.Accordion('3. The facts', open=True):
p_facts = gr.Textbox(label='Facts, one per line: date | what happened | source document | tags (optional: injury, refusal, knowledge, notified, s75, omit, separate). Plain facts only - opinion, rhetoric and document references are stripped and listed in the notes',
lines=10, max_lines=200,
placeholder='2026-03-21 | the front brake failed and I was thrown onto the road | my witness statement | incident, injury')
with gr.Row():
p_x_files = gr.File(label='Optional: extract dated sentences from documents (e.g. your merged document)',
type='filepath', file_count='multiple')
p_x_paste = gr.Textbox(label='...or from pasted text', lines=4)
p_x_btn = gr.Button('Extract dated facts (word for word) into the facts box')
p_x_note = gr.Markdown()
with gr.Accordion('4. Causes of action and particulars', open=True):
p_causes = gr.CheckboxGroup([(v, k) for k, v in poc.CAUSES.items()], label='Causes of action')
gr.Markdown('Fill in only the boxes for the causes you ticked. Use one item per line.')
with gr.Row():
p_breaches = gr.Textbox(label='Contract: breaches of express terms', lines=3)
p_defects = gr.Textbox(label='Goods: defects', lines=3)
p_failings = gr.Textbox(label='Negligence: failings', lines=3)
with gr.Row():
p_desc = gr.Textbox(label='CRA s.11: description the goods were sold by')
p_desc_mis = gr.Textbox(label='CRA s.11: how they did not match it', lines=2)
p_purpose = gr.Textbox(label='CRA s.10: particular purpose')
p_purpose_how = gr.Textbox(label='CRA s.10: how it was made known')
with gr.Row():
p_defect_d = gr.Textbox(label='Date the defect became apparent')
p_rej_d = gr.Textbox(label='Date goods rejected')
p_rej_how = gr.Textbox(label='How rejected', placeholder='e.g. by email to the Defendant')
p_cpa_role = gr.Dropdown(['', 'producer', 'own-brander', 'importer'], value='', label='CPA 1987: Defendant is the')
with gr.Row():
p_svc_fail = gr.Textbox(label='CRA s.49: failures of care and skill', lines=2)
p_duty = gr.Textbox(label='Negligence: scope of the duty (optional)')
p_incident = gr.Textbox(label='Negligence: the incident', placeholder='e.g. accident on 21 March 2026')
with gr.Row():
p_reps = gr.Textbox(label='Misrepresentation: date | how made, by whom | words used', lines=2)
p_falsity = gr.Textbox(label='Why each was false', lines=2)
p_knowledge = gr.Textbox(label='Deceit: facts showing knowledge or recklessness', lines=2)
p_rescind = gr.Textbox(label='Rescission: what the Claimant is ready and willing to return', placeholder='e.g. the Bicycle')
with gr.Row():
p_card = gr.Textbox(label='s.75: credit card agreement', placeholder='a Visa credit card agreement, account ending 1234')
p_psr_tx = gr.Textbox(label='PSRs: transactions, one per line: date | amount | payee/description', lines=2)
p_psr_how = gr.Textbox(label='PSRs (defective execution): what went wrong')
with gr.Row():
p_bsd = gr.Textbox(label='Breach of statutory duty: provision | duty | how breached', lines=2)
p_ue_amt = gr.Textbox(label='Unjust enrichment: sum received (£)')
p_ue_how = gr.Textbox(label='How it was received', placeholder='being the deposit the Claimant paid on 1 May 2026')
p_ue_factor = gr.Dropdown(['failure of basis', 'mistake'], value='failure of basis', label='Why it is unjust')
p_ue_basis = gr.Textbox(label='The basis that failed / the mistake')
with gr.Row():
p_imp = gr.Textbox(label='EqA: impairment')
p_effects = gr.Textbox(label='EqA: effects on day-to-day activities', lines=2)
p_service = gr.Textbox(label='EqA: the service provided')
with gr.Row():
p_arising = gr.Textbox(label='EqA s.15: the "something arising"')
p_unfav = gr.Textbox(label='EqA s.15: unfavourable treatment', lines=2)
p_pcp = gr.Textbox(label='EqA s.19/20: provision, criterion or practice')
p_disadv = gr.Textbox(label='EqA s.19/20: disadvantage')
with gr.Row():
p_adj = gr.Textbox(label='EqA s.20: adjustments that should have been made', lines=2)
p_harass = gr.Textbox(label='EqA s.26 / PHA: conduct relied on', lines=2)
p_pdata = gr.Textbox(label='GDPR: personal data concerned')
p_gdpr = gr.Textbox(label='GDPR: Article | what the Defendant did', lines=2, placeholder='5(1)(d) | recorded inaccurate arrears')
with gr.Accordion('5. Loss, interest and remedies', open=True):
p_losses = gr.Textbox(label='Losses, one per line: description | amount (or "general") | which claims caused it (optional, e.g. cra_goods, negligence)', lines=4)
with gr.Row():
p_injuries = gr.Textbox(label='Injuries (personal injury), one per line', lines=2)
p_medrep = gr.Textbox(label='Medical report relied on (expert and date)')
with gr.Row():
p_aggr = gr.Textbox(label='Grounds for aggravated damages (optional)')
p_injn = gr.Textbox(label='Injunction sought (optional)', placeholder='restraining the Defendant from ...')
with gr.Row():
p_i_sum = gr.Textbox(label='Interest: specified sum (£)')
p_i_rate = gr.Textbox(label='Rate % per year', placeholder='e.g. 8')
p_i_from = gr.Textbox(label='From (date)')
p_i_to = gr.Textbox(label='To (date)')
p_value = gr.Textbox(label='Statement of value (optional)', placeholder='e.g. not more than £10,000')
p_btn = gr.Button('Draft Particulars of Claim', variant='primary', size='lg')
with gr.Row():
p_docx = gr.File(label='Word (.docx)', interactive=False)
p_pdf = gr.File(label='PDF', interactive=False)
p_txt = gr.File(label='Text', interactive=False)
p_notes = gr.File(label='Drafting notes (not for service)', interactive=False)
p_note = gr.Markdown()
p_text = gr.Textbox(label='Draft Particulars of Claim', lines=30, max_lines=3000)
with gr.Accordion('Advanced: draft from JSON (all fields as one object)', open=False):
p_json = gr.Textbox(label='JSON', lines=6)
p_json_btn = gr.Button('Draft from JSON')
poc_inputs = [p_court, p_court_detail, p_claim_no, p_cl_name, p_cl_pron, p_cl_co, p_cl_cap, p_dob, p_addr, p_signed,
p_defs, p_terms, p_goods, p_c_how, p_c_ref, p_c_date, p_c_obl, p_price, p_paid_d, p_paid_h, p_deliv,
p_express, p_facts, p_causes, p_breaches, p_desc, p_desc_mis, p_purpose, p_purpose_how, p_defects,
p_defect_d, p_rej_d, p_rej_how, p_failings, p_svc_fail, p_duty, p_incident, p_cpa_role, p_reps,
p_falsity, p_knowledge, p_imp, p_effects, p_service, p_arising, p_unfav, p_pcp, p_disadv, p_adj,
p_harass, p_pdata, p_gdpr, p_losses, p_injuries, p_medrep, p_aggr, p_injn, p_i_sum, p_i_rate,
p_i_from, p_i_to, p_value, p_against, p_cdef, p_card, p_psr_tx, p_psr_how, p_bsd, p_ue_amt, p_ue_how,
p_ue_factor, p_ue_basis, p_rescind]
poc_outputs = [p_text, p_docx, p_pdf, p_txt, p_notes, p_note]
p_btn.click(draft_poc, inputs=poc_inputs, outputs=poc_outputs, api_name='draft_poc')
p_json_btn.click(draft_poc_json, inputs=[p_json], outputs=poc_outputs, api_name='draft_poc_json')
p_x_btn.click(extract_poc_facts, inputs=[p_x_files, p_x_paste, p_facts], outputs=[p_facts, p_x_note], api_name='extract_facts')
if __name__ == '__main__':
demo.launch(theme=gr.themes.Soft(primary_hue='blue', secondary_hue='sky', neutral_hue='slate'), css=BLUE_CSS)