blessedbyhope05 OpenAI Codex commited on
Commit
aa802c4
·
1 Parent(s): 2e29621

feat: add custom legal evidence desk UI

Browse files

Co-authored-by: OpenAI Codex <codex@openai.com>

Files changed (8) hide show
  1. .gitignore +2 -0
  2. README.md +3 -0
  3. app.py +426 -198
  4. docs/codex-build-log.md +6 -10
  5. index.html +118 -0
  6. static/app.css +616 -0
  7. static/app.js +227 -0
  8. static/lease-lens-mark.svg +9 -0
.gitignore CHANGED
@@ -3,3 +3,5 @@ __pycache__/
3
  .env
4
  .venv/
5
  venv/
 
 
 
3
  .env
4
  .venv/
5
  venv/
6
+ .ui-check/
7
+ mock-server.*.log
README.md CHANGED
@@ -80,6 +80,8 @@ datasets:
80
 
81
  Paste any contract → verbatim risky-clause flags, a risk score, in-text highlighting, plain-English "push back" tips, and a one-click negotiation email. The entire model runs inside this Space — **no external LLM API is ever called**.
82
 
 
 
83
  ## Submission Snapshot
84
 
85
  | | |
@@ -145,6 +147,7 @@ Each is built into the app's dropdown and links to its original SEC filing — c
145
  - **Three guards** keep flags honest: the quote must appear verbatim in the contract, can't repeat across categories, and must contain category-relevant terms.
146
  - **Coverage declaration** on every result: which clause types were checked, which skipped, and how much of the document was read.
147
  - **✉️ Negotiation email**: one click turns the flags into a polite, plain-English push-back email (draft for review).
 
148
 
149
  ## Run it on your own machine (offline)
150
 
 
80
 
81
  Paste any contract → verbatim risky-clause flags, a risk score, in-text highlighting, plain-English "push back" tips, and a one-click negotiation email. The entire model runs inside this Space — **no external LLM API is ever called**.
82
 
83
+ The Space now uses a custom **redline legal evidence desk** frontend around the same Gradio/ZeroGPU backend: a real SEC filing loads by default, the judge path is visible on the first screen, and results render as a risk docket with clause evidence and a negotiation letter panel.
84
+
85
  ## Submission Snapshot
86
 
87
  | | |
 
147
  - **Three guards** keep flags honest: the quote must appear verbatim in the contract, can't repeat across categories, and must contain category-relevant terms.
148
  - **Coverage declaration** on every result: which clause types were checked, which skipped, and how much of the document was read.
149
  - **✉️ Negotiation email**: one click turns the flags into a polite, plain-English push-back email (draft for review).
150
+ - **Custom UI without a build step**: `gradio.Server` serves vanilla `index.html`, `static/app.css`, and `static/app.js`; if `Server` is unavailable, the app falls back to a styled Gradio Blocks interface.
151
 
152
  ## Run it on your own machine (offline)
153
 
app.py CHANGED
@@ -1,13 +1,15 @@
1
- # Lease Lens v2.0 ZeroGPU edition
2
- # Fine-tuned 3B legal model (adapter: giladam01/lease-lens-legal-3b) on @spaces.GPU.
3
- # All clause categories run in ONE batched generate -> seconds, not minutes.
4
  import json
5
  import html as _html
 
 
6
 
7
- import torch
8
- import gradio as gr
9
- from transformers import AutoModelForCausalLM, AutoTokenizer
10
- from peft import PeftModel
11
 
12
  try:
13
  import spaces # provided on HF Spaces (ZeroGPU)
@@ -21,30 +23,42 @@ except ImportError: # local fallback so the file also runs off-Spaces
21
  return deco
22
  spaces = _S()
23
 
 
 
 
 
 
 
24
  BASE = "unsloth/Llama-3.2-3B-Instruct"
25
  ADAPTER = "giladam01/lease-lens-legal-3b"
26
 
27
- tok = AutoTokenizer.from_pretrained(ADAPTER)
28
- tok.pad_token = tok.pad_token or tok.eos_token
29
- tok.padding_side = "left" # decoder-only batch generation
30
- _base = AutoModelForCausalLM.from_pretrained(BASE, dtype=torch.bfloat16)
31
- # ZeroGPU FIX: at startup the platform emulates CUDA, which tricks PEFT into loading
32
- # adapter tensors straight onto a GPU that doesn't physically exist yet
33
- # ("No CUDA GPUs are available"). Force the adapter load onto CPU, merge there,
34
- # THEN move the merged model to (emulated) CUDA — the supported pattern.
35
- try:
36
- model = PeftModel.from_pretrained(_base, ADAPTER, torch_device="cpu")
37
- except TypeError: # older peft without torch_device kwarg: hide CUDA during the load
38
- _avail, _cnt = torch.cuda.is_available, torch.cuda.device_count
39
- torch.cuda.is_available = lambda: False
40
- torch.cuda.device_count = lambda: 0
41
  try:
42
- model = PeftModel.from_pretrained(_base, ADAPTER)
43
- finally:
44
- torch.cuda.is_available, torch.cuda.device_count = _avail, _cnt
45
- model = model.merge_and_unload()
46
- model.to("cuda") # ZeroGPU emulates CUDA at startup; real GPU attaches inside @spaces.GPU
47
- model.eval()
 
 
 
 
 
 
 
48
 
49
  SYSTEM = ("You are a meticulous legal contract analyst. Given a contract excerpt and a clause "
50
  "category, extract the exact verbatim text of any clause that matches that category. "
@@ -85,60 +99,171 @@ def run_batch(user_msgs, max_new_tokens=128):
85
  return outs
86
 
87
 
88
- def highlight(contract, snippets):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  esc = _html.escape(contract)
90
  for s in snippets:
91
- es = _html.escape(s.strip())
92
  if len(es) > 3 and es in esc:
93
- esc = esc.replace(es, '<mark style="background:#ffd86b;color:#1a1a1a;border-radius:3px">' + es + '</mark>', 1)
94
- return ('<div style="white-space:pre-wrap;font-family:Georgia,\'Iowan Old Style\',serif;font-size:14px;'
95
- 'line-height:1.7;color:#e2e8f0;background:#0d1220;border:1px solid #27314e;padding:16px 18px;'
96
- 'border-radius:10px;max-height:440px;overflow:auto">' + esc + '</div>')
97
 
98
 
99
- def render_results(findings, skipped, n_checked):
100
  score = sum(2 if f["risk"] == "high" else 1 for f in findings)
101
  maxscore = sum(2 if c["risk"] == "high" else 1 for c in CLAUSES)
102
  risk_pct = round(100 * score / maxscore) if maxscore else 0
103
  high_n = sum(1 for f in findings if f["risk"] == "high")
104
  verdict = "High risk" if high_n else ("Some risk" if findings else "Looks clean")
105
- vcolor = "#fc8181" if high_n else ("#f6ad55" if findings else "#68d391") # softened for dark mode
106
- head = ('<div style="font-family:Inter,system-ui,sans-serif;display:flex;gap:20px;align-items:center;'
107
- 'background:#151d30;border:1px solid #27314e;border-radius:14px;padding:16px 20px;margin-bottom:14px">'
108
- '<div style="font-size:40px;font-weight:700;color:' + vcolor + '">' + str(risk_pct) +
109
- '<span style="font-size:18px;color:#9aa6c4">/100</span></div>'
110
- '<div><div style="font-size:18px;font-weight:600;color:' + vcolor + '">' + verdict + '</div>'
111
- '<div style="color:#9aa6c4;font-size:14px">' + str(len(findings)) + ' clauses flagged (' + str(high_n) +
112
- ' high-risk) of ' + str(n_checked) + ' checked</div></div></div>')
113
- cards = []
114
- for f in sorted(findings, key=lambda x: 0 if x["risk"] == "high" else 1):
115
- color = "#fc8181" if f["risk"] == "high" else "#f6ad55"
116
- tag = "High risk" if f["risk"] == "high" else "Review"
117
- cards.append(
118
- '<div style="border-left:5px solid ' + color + ';background:rgba(255,255,255,0.05);border-radius:10px;'
119
- 'padding:14px 16px;margin-bottom:14px;color:#e2e8f0;font-family:Inter,system-ui,sans-serif">'
120
- '<div style="display:flex;gap:10px;align-items:center"><b style="font-size:16px;color:#f8fafc">' + _html.escape(f["label"]) + '</b>'
121
- '<span style="font-size:11px;color:' + color + ';border:1px solid ' + color + ';border-radius:6px;padding:2px 8px">' + tag + '</span></div>'
122
- '<div style="font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;color:#e2e8f0;background:#0d1220;'
123
- 'border-radius:8px;padding:11px 13px;margin:11px 0;white-space:pre-wrap;line-height:1.55">' + _html.escape(f["text"]) + '</div>'
124
- '<div style="color:#cbd5e0;font-size:14px;line-height:1.5">💡 <b style="color:#90cdf4">Why this matters:</b> ' + _html.escape(f["why"]) + '</div>'
125
- '<div style="color:#9ae6b4;font-size:14px;margin-top:6px;line-height:1.5">✋ <b style="color:#9ae6b4">Push back:</b> ' + _html.escape(f["tip"]) + '</div></div>')
126
- body = head + ("".join(cards) if findings else
127
- '<p style="color:#5fe0a0;font-family:Inter,sans-serif">No risky clauses flagged.</p>')
128
- if skipped:
129
- body += ('<div style="font-family:Inter,system-ui,sans-serif;color:#9aa6c4;font-size:13px;margin-top:10px">'
130
- 'Coverage: checked ' + str(n_checked) + ' of ' + str(len(CLAUSES)) +
131
- ' clause types; skipped (keywords absent): ' + ", ".join(skipped) + '</div>')
132
- body += ('<div style="font-family:Inter,system-ui,sans-serif;color:#6b7689;font-size:12px;margin-top:8px">'
133
- 'Not legal advice — every flag is a draft for review.</div>')
134
- return body
135
-
136
-
137
- def analyze(text):
 
 
 
 
138
  text = (text or "").strip()
139
  if len(text) < 40:
140
- yield '<p style="color:#ffb653;font-family:Inter,sans-serif">Paste or pick a contract first.</p>', "", "[]"
141
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  cn = " ".join(text.lower().split())
143
  # CHUNKED ANALYSIS: real contracts are long. Split into overlapping windows (first 80k
144
  # chars) and route each clause category only to windows containing its keywords.
@@ -146,66 +271,77 @@ def analyze(text):
146
  body_text = text[:CAP]
147
  chunks = [body_text[s:s + WIN] for s in range(0, max(len(body_text), 1), STRIDE)]
148
  chunks = [c for c in chunks if len(c) > 200] or [body_text]
149
- pairs = [] # (clause, chunk_text)
150
  for c in CLAUSES:
151
  hit = [ch for ch in chunks if any(k in " ".join(ch.lower().split()) for k in c["kw"])]
152
- for ch in hit[:6]: # cap windows per category
153
  pairs.append((c, ch))
154
  covered = {c["label"] for c, _ in pairs}
155
  skipped = [c["label"] for c in CLAUSES if c["label"] not in covered]
156
  if not pairs:
157
- yield ('<p style="color:#5fe0a0;font-family:Inter,sans-serif">No risky clauses flagged '
158
- '(no clause keywords present in this text).</p>'), highlight(text, []), "[]"
159
- return
160
- yield ('<div style="font-family:Inter,system-ui,sans-serif;color:#9aa6c4;background:#151d30;border:1px solid #27314e;'
161
- 'border-radius:12px;padding:12px 16px">⚡ Running ' + str(len(pairs)) + ' checks across ' +
162
- str(len(chunks)) + ' document windows (' + str(len(body_text)) +
163
- ' chars) in batched passes on ZeroGPU…</div>'), highlight(text, []), "[]"
164
 
165
  msgs = [("Highlight any part of this contract related to: " + c["cat"] +
166
  ". If there is none, reply NONE.\n\n---\nContract:\n" + ch) for c, ch in pairs]
167
  try:
168
  answers = run_batch(msgs)
169
  except Exception as e:
170
- yield ('<div style="color:#ff5d6c;font-family:Inter,sans-serif">GPU call failed: ' +
171
- _html.escape(str(e)[:200]) + ' — try again in a minute (ZeroGPU queue).</div>'), highlight(text, []), "[]"
172
- return
 
 
 
173
 
174
  findings, snippets, used, found_cats = [], [], [], set()
175
  for (c, _ch), a in zip(pairs, answers):
176
- if c["label"] in found_cats: # first good hit wins
177
  continue
178
  a = (a or "").strip()
179
  if a.upper() == "NONE" or len(a) <= 3:
180
  continue
181
  cand = a.split(" | ")[0].strip()
182
  ncs = " ".join(cand.lower().split())
183
- if len(ncs) < 12 or ncs[:60] not in cn: # grounding (full doc)
184
  continue
185
- if any(ncs[:80] == u[:80] or ncs in u or u in ncs for u in used): # dedup
186
  continue
187
- if not any(k in ncs for k in c["kw"]): # keyword guard
188
  continue
189
- used.append(ncs); snippets.append(cand)
190
- findings.append({**c, "text": cand}); found_cats.add(c["label"])
 
 
191
 
192
- state = json.dumps([{"label": f["label"], "text": f["text"], "tip": f["tip"]} for f in findings])
193
- extra = ('' if len(text) <= CAP else
194
- '<div style="font-family:Inter,system-ui,sans-serif;color:#9aa6c4;font-size:13px;margin-top:6px">'
195
- 'Note: analyzed the first ' + str(CAP) + ' of ' + str(len(text)) + ' characters.</div>')
196
- yield render_results(findings, skipped, len(covered)) + extra, highlight(text, snippets), state
197
 
198
 
199
- def draft_email(state):
200
  try:
201
- findings = json.loads(state or "[]")
202
  except Exception:
203
  findings = []
204
  if not findings:
205
- return "Run an analysis first then I can draft the email from the flagged clauses."
206
  lines = []
207
  for f in findings:
208
  lines.append("- " + f["label"] + ': "' + f["text"][:220] + '" -> ask: ' + f["tip"])
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  msg = ("Write a short, polite, plain-English email to the other party of a contract, "
210
  "proposing changes to these flagged clauses. No legalese (say 'under' not 'pursuant to'). "
211
  "For each clause: what it currently says, and the change we request. Factual tone; do not "
@@ -214,125 +350,217 @@ def draft_email(state):
214
  try:
215
  out = run_batch([msg], max_new_tokens=400)[0]
216
  except Exception as e:
217
- return "GPU call failed: " + str(e)[:200] + " try again shortly."
218
- return out + "\n\n---\nDraft for review not legal advice."
219
 
220
 
221
- EXAMPLES = {
222
- "Apartment lease": """RESIDENTIAL LEASE AGREEMENT (excerpt)
 
223
 
224
- 3. TERM. This Lease begins July 1 for twelve (12) months. Unless either party gives written notice at least ninety (90) days before the end of the term, this Lease automatically renews for successive 12-month terms at the then-current market rent.
225
- 4. RENT. Monthly rent is $2,400. Landlord may increase the rent by up to eight percent (8%) upon each renewal. Any payment received after the 5th incurs a late fee of $150 plus $25 per day.
226
- 7. EARLY TERMINATION. Tenant may terminate early only upon payment of two (2) months' rent, and forfeits the entire security deposit.
227
- 11. MAINTENANCE. Tenant is responsible for all repairs and maintenance, including HVAC, plumbing, and appliances, regardless of cause.
228
- 15. ENTRY. Landlord may enter the premises at any time to inspect, repair, or show the unit.
229
- 19. DISPUTES. The parties waive any right to a jury trial; all disputes shall be resolved by binding arbitration in the county of Landlord's choosing. Tenant waives the right to participate in any class action.""",
230
 
231
- "Freelance contract (NDA + IP)": """INDEPENDENT CONTRACTOR AGREEMENT (excerpt)
 
 
 
232
 
233
- 2. INTELLECTUAL PROPERTY. All work product, and any pre-existing materials incorporated therein, are hereby assigned exclusively to Client upon creation.
234
- 3. NON-COMPETE. For twenty-four (24) months after termination, Contractor shall not provide similar services to any competitor of Client anywhere in the United States.
235
- 4. PAYMENT. Net-60. Client may terminate at any time without cause, in which case Contractor forfeits any unpaid fees for work in progress.
236
- 6. INDEMNIFICATION. Contractor shall indemnify and hold Client harmless from any and all claims arising from the work, regardless of fault.
237
- 9. DISPUTES. Any dispute shall be resolved by binding arbitration. Contractor waives the right to a jury trial and to participate in any class or collective action.""",
238
 
239
- "SaaS Terms of Service": """SOFTWARE SUBSCRIPTION TERMS (excerpt)
240
 
241
- 4. TERM & RENEWAL. Your subscription automatically renews for successive annual terms unless cancelled at least sixty (60) days before renewal. We may increase fees by up to fifteen percent (15%) on each renewal.
242
- 6. CANCELLATION. To cancel you must send written notice; cancellation is effective at the end of the then-current term. No refunds are provided.
243
- 8. LIABILITY. Our total liability is limited to the fees you paid in the one (1) month preceding the claim. We may modify or discontinue the Service at any time without notice.
244
- 12. DISPUTES. You agree to binding arbitration and waive any right to a jury trial or to participate in a class action.""",
245
-
246
- "Gym membership": """FITNESS MEMBERSHIP AGREEMENT (excerpt)
247
-
248
- 1. TERM. Twelve (12) month minimum commitment. After the initial term, membership automatically continues month-to-month at the then-current rate.
249
- 2. CANCELLATION. Cancellation requires thirty (30) days' written notice delivered by certified mail. Early cancellation within the initial term requires payment of all remaining monthly dues.
250
- 3. FEES. Dues are billed monthly. A late payment incurs a $40 fee plus any collection costs and reasonable attorneys' fees.
251
- 5. RELEASE OF LIABILITY. Member uses all facilities at Member's own risk and releases the Gym from any and all liability, including claims arising from the Gym's own negligence.""",
252
- }
253
-
254
- CSS = """
255
- footer{display:none!important}
256
- .gradio-container{background:#0d1220!important; max-width:1180px!important}
257
- #hdr h1{color:#eaeefb; font-family:Inter,system-ui,sans-serif; margin-bottom:0}
258
- #hdr p{color:#9aa6c4; font-family:Inter,system-ui,sans-serif}
259
- #hdr .chip{display:inline-block;font-family:monospace;font-size:12px;color:#39d3c5;background:rgba(57,211,197,.1);
260
- border:1px solid rgba(57,211,197,.35);padding:4px 10px;border-radius:999px;margin-right:6px}
261
- #go_row{margin:14px 0}
262
- """
263
-
264
- EMPTY_STATE = ('<div style="font-family:Inter,system-ui,sans-serif;color:#9aa6c4;text-align:center;'
265
- 'background:rgba(255,255,255,0.03);border:1px dashed #27314e;border-radius:12px;padding:34px 20px">'
266
- '<div style="font-size:34px;margin-bottom:8px">🔍</div>'
267
- 'Pick an example (or upload a contract) above and press <b style="color:#e2e8f0">⚡ Analyze contract</b> '
268
- 'to see the risk score, flagged clauses, and highlights here.</div>')
269
 
 
 
 
270
 
271
- # Real executed leases from SEC EDGAR filings (public record) — judges can analyze a genuine
272
- # contract in one click, not just the teaching samples.
273
- REAL_SOURCES = {}
274
- try:
275
- from sample_contracts import REAL_EXAMPLES, REAL_SOURCES
276
- EXAMPLES = {**REAL_EXAMPLES, **EXAMPLES}
277
- except Exception as _e:
278
- print("sample_contracts not loaded:", _e)
279
 
 
 
 
280
 
281
- def _source_banner(name):
282
- url = REAL_SOURCES.get(name)
283
- if not url:
284
- return "" # synthetic teaching sample — no source banner
285
- return ('<div style="font-family:Inter,system-ui,sans-serif;font-size:13px;color:#90cdf4;'
286
- 'background:rgba(144,205,244,0.08);border:1px solid rgba(144,205,244,0.3);'
287
- 'border-radius:10px;padding:10px 14px;margin-bottom:8px">'
288
- '📄 <b>Real executed contract</b>, public SEC filing — '
289
- '<a href="' + url + '" target="_blank" rel="noopener noreferrer" '
290
- 'style="color:#90cdf4;text-decoration:underline">verify the source on sec.gov ↗</a> · '
291
- 'outside the model\'s training data.</div>')
292
 
 
 
 
 
293
 
294
- def load_example(name):
295
- # returns (contract_text, source_banner_html)
296
- return EXAMPLES.get(name, ""), _source_banner(name)
297
 
 
 
 
 
 
 
298
 
299
- def load_file(path):
300
- if not path:
301
- return "", ""
302
- with open(path, "r", errors="ignore") as fh:
303
- return fh.read(), "" # uploaded file: no SEC source banner
304
 
305
 
306
- DEFAULT_EXAMPLE = next(iter(EXAMPLES))
 
 
 
 
 
 
 
 
 
307
 
308
 
309
- # Gradio 5.x (pinned via README sdk_version): css belongs in the Blocks constructor.
310
- with gr.Blocks(css=CSS, title="Lease Lens") as demo:
311
- gr.HTML('<div id="hdr"><h1>🔍 Lease Lens</h1>'
312
- '<p>Paste a lease or contract — a <b>fine-tuned 3B legal model</b> scores the risk, flags clauses '
313
- 'verbatim, highlights them in the text, and drafts your negotiation email.</p>'
314
- '<span class="chip">⚡ ZeroGPU · seconds per analysis</span>'
315
- '<span class="chip">3B fine-tune · +242% F1 vs base</span>'
316
- '<span class="chip">also ships as GGUF for llama.cpp</span></div>')
317
- with gr.Row():
318
- ex = gr.Dropdown(choices=list(EXAMPLES.keys()), value=DEFAULT_EXAMPLE, label="Load a real-world example")
319
- up = gr.File(label="…or upload your own .txt contract", file_types=[".txt"], type="filepath")
320
- src_banner = gr.HTML(value=_source_banner(DEFAULT_EXAMPLE)) # shows SEC provenance when a real lease is selected
321
- inp = gr.Textbox(value=EXAMPLES[DEFAULT_EXAMPLE], lines=10, max_lines=20, label="Contract text")
322
- with gr.Row(elem_id="go_row"):
323
- btn = gr.Button("⚡ Analyze contract", variant="primary", scale=4)
324
- clear_btn = gr.Button("Clear", variant="secondary", scale=1)
325
- st = gr.State("[]")
326
- with gr.Row():
327
- out_cards = gr.HTML(value=EMPTY_STATE)
328
- out_doc = gr.HTML()
329
- with gr.Accordion("✉️ Draft a negotiation email from the flags", open=False):
330
- email_btn = gr.Button("Draft email")
331
- email_out = gr.Textbox(lines=12, label="Draft (copy-paste, edit before sending)", show_copy_button=True)
332
- ex.change(load_example, ex, [inp, src_banner])
333
- up.upload(load_file, up, [inp, src_banner])
334
- btn.click(analyze, inp, [out_cards, out_doc, st])
335
- email_btn.click(draft_email, st, email_out)
336
- clear_btn.click(lambda: ("", "", EMPTY_STATE, "", "[]"), None, [inp, src_banner, out_cards, out_doc, st])
337
-
338
- demo.queue().launch(ssr_mode=False, show_error=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Lease Lens v3.0 - custom evidence desk UI
2
+ # Fine-tuned 3B legal model (adapter: giladam01/lease-lens-legal-3b) on ZeroGPU.
3
+ # Model, prompts, extraction guards, scoring, and generation behavior are kept stable.
4
  import json
5
  import html as _html
6
+ import os
7
+ from pathlib import Path
8
 
9
+ try:
10
+ import gradio as gr
11
+ except ImportError: # local mock mode can still serve the custom frontend without Gradio.
12
+ gr = None
13
 
14
  try:
15
  import spaces # provided on HF Spaces (ZeroGPU)
 
23
  return deco
24
  spaces = _S()
25
 
26
+
27
+ ROOT = Path(__file__).resolve().parent
28
+ INDEX_HTML = ROOT / "index.html"
29
+ STATIC_DIR = ROOT / "static"
30
+ MOCK_MODE = os.getenv("LEASE_LENS_MOCK", "").strip() == "1"
31
+
32
  BASE = "unsloth/Llama-3.2-3B-Instruct"
33
  ADAPTER = "giladam01/lease-lens-legal-3b"
34
 
35
+ if not MOCK_MODE:
36
+ import torch
37
+ from transformers import AutoModelForCausalLM, AutoTokenizer
38
+ from peft import PeftModel
39
+
40
+ tok = AutoTokenizer.from_pretrained(ADAPTER)
41
+ tok.pad_token = tok.pad_token or tok.eos_token
42
+ tok.padding_side = "left" # decoder-only batch generation
43
+ _base = AutoModelForCausalLM.from_pretrained(BASE, dtype=torch.bfloat16)
44
+ # ZeroGPU FIX: at startup the platform emulates CUDA, which tricks PEFT into loading
45
+ # adapter tensors straight onto a GPU that doesn't physically exist yet
46
+ # ("No CUDA GPUs are available"). Force the adapter load onto CPU, merge there,
47
+ # THEN move the merged model to (emulated) CUDA - the supported pattern.
 
48
  try:
49
+ model = PeftModel.from_pretrained(_base, ADAPTER, torch_device="cpu")
50
+ except TypeError: # older peft without torch_device kwarg: hide CUDA during the load
51
+ _avail, _cnt = torch.cuda.is_available, torch.cuda.device_count
52
+ torch.cuda.is_available = lambda: False
53
+ torch.cuda.device_count = lambda: 0
54
+ try:
55
+ model = PeftModel.from_pretrained(_base, ADAPTER)
56
+ finally:
57
+ torch.cuda.is_available, torch.cuda.device_count = _avail, _cnt
58
+ model = model.merge_and_unload()
59
+ model.to("cuda") # ZeroGPU emulates CUDA at startup; real GPU attaches inside @spaces.GPU
60
+ model.eval()
61
+
62
 
63
  SYSTEM = ("You are a meticulous legal contract analyst. Given a contract excerpt and a clause "
64
  "category, extract the exact verbatim text of any clause that matches that category. "
 
99
  return outs
100
 
101
 
102
+ EXAMPLES = {
103
+ "Apartment lease": """RESIDENTIAL LEASE AGREEMENT (excerpt)
104
+
105
+ 3. TERM. This Lease begins July 1 for twelve (12) months. Unless either party gives written notice at least ninety (90) days before the end of the term, this Lease automatically renews for successive 12-month terms at the then-current market rent.
106
+ 4. RENT. Monthly rent is $2,400. Landlord may increase the rent by up to eight percent (8%) upon each renewal. Any payment received after the 5th incurs a late fee of $150 plus $25 per day.
107
+ 7. EARLY TERMINATION. Tenant may terminate early only upon payment of two (2) months' rent, and forfeits the entire security deposit.
108
+ 11. MAINTENANCE. Tenant is responsible for all repairs and maintenance, including HVAC, plumbing, and appliances, regardless of cause.
109
+ 15. ENTRY. Landlord may enter the premises at any time to inspect, repair, or show the unit.
110
+ 19. DISPUTES. The parties waive any right to a jury trial; all disputes shall be resolved by binding arbitration in the county of Landlord's choosing. Tenant waives the right to participate in any class action.""",
111
+
112
+ "Freelance contract (NDA + IP)": """INDEPENDENT CONTRACTOR AGREEMENT (excerpt)
113
+
114
+ 2. INTELLECTUAL PROPERTY. All work product, and any pre-existing materials incorporated therein, are hereby assigned exclusively to Client upon creation.
115
+ 3. NON-COMPETE. For twenty-four (24) months after termination, Contractor shall not provide similar services to any competitor of Client anywhere in the United States.
116
+ 4. PAYMENT. Net-60. Client may terminate at any time without cause, in which case Contractor forfeits any unpaid fees for work in progress.
117
+ 6. INDEMNIFICATION. Contractor shall indemnify and hold Client harmless from any and all claims arising from the work, regardless of fault.
118
+ 9. DISPUTES. Any dispute shall be resolved by binding arbitration. Contractor waives the right to a jury trial and to participate in any class or collective action.""",
119
+
120
+ "SaaS Terms of Service": """SOFTWARE SUBSCRIPTION TERMS (excerpt)
121
+
122
+ 4. TERM & RENEWAL. Your subscription automatically renews for successive annual terms unless cancelled at least sixty (60) days before renewal. We may increase fees by up to fifteen percent (15%) on each renewal.
123
+ 6. CANCELLATION. To cancel you must send written notice; cancellation is effective at the end of the then-current term. No refunds are provided.
124
+ 8. LIABILITY. Our total liability is limited to the fees you paid in the one (1) month preceding the claim. We may modify or discontinue the Service at any time without notice.
125
+ 12. DISPUTES. You agree to binding arbitration and waive any right to a jury trial or to participate in a class action.""",
126
+
127
+ "Gym membership": """FITNESS MEMBERSHIP AGREEMENT (excerpt)
128
+
129
+ 1. TERM. Twelve (12) month minimum commitment. After the initial term, membership automatically continues month-to-month at the then-current rate.
130
+ 2. CANCELLATION. Cancellation requires thirty (30) days' written notice delivered by certified mail. Early cancellation within the initial term requires payment of all remaining monthly dues.
131
+ 3. FEES. Dues are billed monthly. A late payment incurs a $40 fee plus any collection costs and reasonable attorneys' fees.
132
+ 5. RELEASE OF LIABILITY. Member uses all facilities at Member's own risk and releases the Gym from any and all liability, including claims arising from the Gym's own negligence.""",
133
+ }
134
+
135
+ # Real executed leases from SEC EDGAR filings (public record) - judges can analyze a genuine
136
+ # contract in one click, not just the teaching samples.
137
+ REAL_SOURCES = {}
138
+ try:
139
+ from sample_contracts import REAL_EXAMPLES, REAL_SOURCES
140
+ EXAMPLES = {**REAL_EXAMPLES, **EXAMPLES}
141
+ except Exception as _e:
142
+ print("sample_contracts not loaded:", _e)
143
+
144
+ DEFAULT_EXAMPLE = next(iter(EXAMPLES))
145
+
146
+
147
+ def _source_banner_html(name):
148
+ url = REAL_SOURCES.get(name)
149
+ if not url:
150
+ return ""
151
+ return ('<div class="source-banner">'
152
+ '<span class="source-dot">SEC</span>'
153
+ '<b>Real executed contract</b>, public filing - '
154
+ '<a href="' + _html.escape(url, quote=True) + '" target="_blank" rel="noopener noreferrer">'
155
+ 'verify the source on sec.gov</a> · outside the model training data.</div>')
156
+
157
+
158
+ def get_example_payload(name):
159
+ if name not in EXAMPLES:
160
+ name = DEFAULT_EXAMPLE
161
+ return {
162
+ "name": name,
163
+ "text": EXAMPLES.get(name, ""),
164
+ "source_url": REAL_SOURCES.get(name, ""),
165
+ "source_banner_html": _source_banner_html(name),
166
+ "is_real": name in REAL_SOURCES,
167
+ }
168
+
169
+
170
+ def bootstrap_payload():
171
+ return {
172
+ "examples": list(EXAMPLES.keys()),
173
+ "default_example": DEFAULT_EXAMPLE,
174
+ "proof_chips": [
175
+ "3B fine-tune",
176
+ "+242% F1 vs base",
177
+ "SEC-filed examples",
178
+ "GGUF / llama.cpp",
179
+ "ZeroGPU",
180
+ "No external LLM API",
181
+ ],
182
+ "mock_mode": MOCK_MODE,
183
+ }
184
+
185
+
186
+ def highlight_html(contract, snippets):
187
  esc = _html.escape(contract)
188
  for s in snippets:
189
+ es = _html.escape((s or "").strip())
190
  if len(es) > 3 and es in esc:
191
+ esc = esc.replace(es, '<mark>' + es + '</mark>', 1)
192
+ return '<div class="contract-page">' + esc + '</div>'
 
 
193
 
194
 
195
+ def _score_findings(findings):
196
  score = sum(2 if f["risk"] == "high" else 1 for f in findings)
197
  maxscore = sum(2 if c["risk"] == "high" else 1 for c in CLAUSES)
198
  risk_pct = round(100 * score / maxscore) if maxscore else 0
199
  high_n = sum(1 for f in findings if f["risk"] == "high")
200
  verdict = "High risk" if high_n else ("Some risk" if findings else "Looks clean")
201
+ return risk_pct, high_n, verdict
202
+
203
+
204
+ def _analysis_payload(text, findings, skipped, n_checked, snippets, chunk_count=0, char_count=0, note=""):
205
+ risk_pct, high_n, verdict = _score_findings(findings)
206
+ return {
207
+ "status": "ok",
208
+ "score": risk_pct,
209
+ "verdict": verdict,
210
+ "high_count": high_n,
211
+ "flag_count": len(findings),
212
+ "checked_count": n_checked,
213
+ "total_clause_count": len(CLAUSES),
214
+ "skipped": skipped,
215
+ "findings": findings,
216
+ "snippets": snippets,
217
+ "highlighted_html": highlight_html(text, snippets),
218
+ "coverage_note": note,
219
+ "chunk_count": chunk_count,
220
+ "char_count": char_count,
221
+ "mock_mode": MOCK_MODE,
222
+ "disclaimer": "Not legal advice - every flag is a draft for review.",
223
+ }
224
+
225
+
226
+ def _quote_near_keyword(text, keywords):
227
+ lower = text.lower()
228
+ hits = [lower.find(k) for k in keywords if lower.find(k) >= 0]
229
+ if not hits:
230
+ return ""
231
+ idx = min(hits)
232
+ start = max(0, idx - 180)
233
+ end = min(len(text), idx + 420)
234
+ return text[start:end].strip()
235
+
236
+
237
+ def _mock_analyze_contract(text):
238
  text = (text or "").strip()
239
  if len(text) < 40:
240
+ return {"status": "empty", "message": "Paste or pick a contract first.", "findings": []}
241
+ findings, snippets, covered = [], [], set()
242
+ for c in CLAUSES:
243
+ quote = _quote_near_keyword(text, c["kw"])
244
+ if not quote:
245
+ continue
246
+ covered.add(c["label"])
247
+ if len(findings) >= 5:
248
+ continue
249
+ snippets.append(quote)
250
+ findings.append({**c, "text": quote})
251
+ skipped = [c["label"] for c in CLAUSES if c["label"] not in covered]
252
+ if not findings and covered:
253
+ findings.append({**CLAUSES[0], "text": text[:420].strip()})
254
+ snippets.append(findings[0]["text"])
255
+ return _analysis_payload(text, findings, skipped, len(covered), snippets,
256
+ chunk_count=1, char_count=len(text),
257
+ note="Mock UI mode: deterministic local preview, not model output.")
258
+
259
+
260
+ def analyze_contract_payload(text):
261
+ if MOCK_MODE:
262
+ return _mock_analyze_contract(text)
263
+
264
+ text = (text or "").strip()
265
+ if len(text) < 40:
266
+ return {"status": "empty", "message": "Paste or pick a contract first.", "findings": []}
267
  cn = " ".join(text.lower().split())
268
  # CHUNKED ANALYSIS: real contracts are long. Split into overlapping windows (first 80k
269
  # chars) and route each clause category only to windows containing its keywords.
 
271
  body_text = text[:CAP]
272
  chunks = [body_text[s:s + WIN] for s in range(0, max(len(body_text), 1), STRIDE)]
273
  chunks = [c for c in chunks if len(c) > 200] or [body_text]
274
+ pairs = [] # (clause, chunk_text)
275
  for c in CLAUSES:
276
  hit = [ch for ch in chunks if any(k in " ".join(ch.lower().split()) for k in c["kw"])]
277
+ for ch in hit[:6]:
278
  pairs.append((c, ch))
279
  covered = {c["label"] for c, _ in pairs}
280
  skipped = [c["label"] for c in CLAUSES if c["label"] not in covered]
281
  if not pairs:
282
+ return _analysis_payload(text, [], skipped, 0, [], len(chunks), len(body_text))
 
 
 
 
 
 
283
 
284
  msgs = [("Highlight any part of this contract related to: " + c["cat"] +
285
  ". If there is none, reply NONE.\n\n---\nContract:\n" + ch) for c, ch in pairs]
286
  try:
287
  answers = run_batch(msgs)
288
  except Exception as e:
289
+ return {
290
+ "status": "error",
291
+ "message": "GPU call failed: " + str(e)[:200] + " - try again in a minute (ZeroGPU queue).",
292
+ "findings": [],
293
+ "highlighted_html": highlight_html(text, []),
294
+ }
295
 
296
  findings, snippets, used, found_cats = [], [], [], set()
297
  for (c, _ch), a in zip(pairs, answers):
298
+ if c["label"] in found_cats:
299
  continue
300
  a = (a or "").strip()
301
  if a.upper() == "NONE" or len(a) <= 3:
302
  continue
303
  cand = a.split(" | ")[0].strip()
304
  ncs = " ".join(cand.lower().split())
305
+ if len(ncs) < 12 or ncs[:60] not in cn:
306
  continue
307
+ if any(ncs[:80] == u[:80] or ncs in u or u in ncs for u in used):
308
  continue
309
+ if not any(k in ncs for k in c["kw"]):
310
  continue
311
+ used.append(ncs)
312
+ snippets.append(cand)
313
+ findings.append({**c, "text": cand})
314
+ found_cats.add(c["label"])
315
 
316
+ note = ""
317
+ if len(text) > CAP:
318
+ note = "Analyzed the first " + str(CAP) + " of " + str(len(text)) + " characters."
319
+ return _analysis_payload(text, findings, skipped, len(covered), snippets, len(chunks), len(body_text), note)
 
320
 
321
 
322
+ def draft_email_payload(state_json):
323
  try:
324
+ findings = json.loads(state_json or "[]")
325
  except Exception:
326
  findings = []
327
  if not findings:
328
+ return {"status": "empty", "email": "Run an analysis first - then I can draft the email from the flagged clauses."}
329
  lines = []
330
  for f in findings:
331
  lines.append("- " + f["label"] + ': "' + f["text"][:220] + '" -> ask: ' + f["tip"])
332
+
333
+ if MOCK_MODE:
334
+ return {
335
+ "status": "ok",
336
+ "email": (
337
+ "Subject: Lease revision requests\n\n"
338
+ "Hi,\n\n"
339
+ "I reviewed the draft and would like to discuss a few clauses before signing:\n\n"
340
+ + "\n".join(lines[:4]) +
341
+ "\n\nCould you send a revised draft reflecting these changes?\n\nThanks,\n\n---\nDraft for review - not legal advice."
342
+ ),
343
+ }
344
+
345
  msg = ("Write a short, polite, plain-English email to the other party of a contract, "
346
  "proposing changes to these flagged clauses. No legalese (say 'under' not 'pursuant to'). "
347
  "For each clause: what it currently says, and the change we request. Factual tone; do not "
 
350
  try:
351
  out = run_batch([msg], max_new_tokens=400)[0]
352
  except Exception as e:
353
+ return {"status": "error", "email": "GPU call failed: " + str(e)[:200] + " - try again shortly."}
354
+ return {"status": "ok", "email": out + "\n\n---\nDraft for review - not legal advice."}
355
 
356
 
357
+ def _index_html():
358
+ html = INDEX_HTML.read_text(encoding="utf-8")
359
+ return html.replace("__LEASE_LENS_BOOTSTRAP__", json.dumps(bootstrap_payload()))
360
 
 
 
 
 
 
 
361
 
362
+ def launch_server():
363
+ Server = getattr(gr, "Server", None)
364
+ if Server is None:
365
+ return launch_blocks_fallback()
366
 
367
+ from fastapi import Request
368
+ from fastapi.responses import HTMLResponse, FileResponse
 
 
 
369
 
370
+ app = Server()
371
 
372
+ @app.api(name="get_example")
373
+ def get_example(name: str):
374
+ return get_example_payload(name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
 
376
+ @app.api(name="analyze_contract")
377
+ def analyze_contract(text: str):
378
+ return analyze_contract_payload(text)
379
 
380
+ @app.api(name="draft_email")
381
+ def draft_email(state_json: str):
382
+ return draft_email_payload(state_json)
 
 
 
 
 
383
 
384
+ @app.get("/api/get_example")
385
+ async def rest_get_example(name: str = DEFAULT_EXAMPLE):
386
+ return get_example_payload(name)
387
 
388
+ @app.post("/api/analyze_contract")
389
+ async def rest_analyze_contract(request: Request):
390
+ data = await request.json()
391
+ return analyze_contract_payload(data.get("text", ""))
 
 
 
 
 
 
 
392
 
393
+ @app.post("/api/draft_email")
394
+ async def rest_draft_email(request: Request):
395
+ data = await request.json()
396
+ return draft_email_payload(data.get("state_json", "[]"))
397
 
398
+ @app.get("/", response_class=HTMLResponse)
399
+ async def homepage():
400
+ return _index_html()
401
 
402
+ @app.get("/static/{file_path:path}")
403
+ async def static_files(file_path: str):
404
+ target = (STATIC_DIR / file_path).resolve()
405
+ if STATIC_DIR.resolve() not in target.parents and target != STATIC_DIR.resolve():
406
+ raise FileNotFoundError(file_path)
407
+ return FileResponse(target)
408
 
409
+ app.launch(show_error=True)
 
 
 
 
410
 
411
 
412
+ CSS_FALLBACK = """
413
+ footer{display:none!important}
414
+ .gradio-container{background:#090b0f!important; max-width:1240px!important}
415
+ #hdr{background:#11151d;border:1px solid #52442c;border-radius:8px;padding:20px 22px;margin-bottom:14px}
416
+ #hdr h1{color:#f4ead7;font-family:Georgia,serif;margin:0 0 4px;font-size:34px}
417
+ #hdr p{color:#c9bdab;font-family:Segoe UI,sans-serif;max-width:760px}
418
+ #hdr .chip{display:inline-block;font-family:ui-monospace,monospace;font-size:12px;color:#6bd7d2;background:rgba(107,215,210,.09);
419
+ border:1px solid rgba(107,215,210,.35);padding:5px 10px;border-radius:999px;margin:5px 6px 0 0}
420
+ #go_row{margin:14px 0}
421
+ """
422
 
423
 
424
+ def _render_blocks_results(data):
425
+ if data.get("status") == "empty":
426
+ return '<div style="color:#e3b15f;font-family:Segoe UI,sans-serif">' + data["message"] + '</div>'
427
+ if data.get("status") == "error":
428
+ return '<div style="color:#ff6b6b;font-family:Segoe UI,sans-serif">' + _html.escape(data["message"]) + '</div>'
429
+ cards = [
430
+ '<div style="background:#11151d;border:1px solid #52442c;border-radius:8px;padding:16px;color:#f4ead7;font-family:Segoe UI,sans-serif;margin-bottom:12px">'
431
+ '<div style="font-family:Georgia,serif;font-size:38px;color:#b73737">' + str(data["score"]) + '<span style="font-size:16px;color:#9f927d">/100</span></div>'
432
+ '<b>' + _html.escape(data["verdict"]) + '</b><br>'
433
+ '<span style="color:#9f927d">' + str(data["flag_count"]) + ' clauses flagged of ' + str(data["checked_count"]) + ' checked</span></div>'
434
+ ]
435
+ for f in data.get("findings", []):
436
+ cards.append('<div style="border-left:4px solid #b73737;background:#121821;border-radius:8px;padding:13px;margin-bottom:10px;color:#f4ead7">'
437
+ '<b>' + _html.escape(f["label"]) + '</b>'
438
+ '<pre style="white-space:pre-wrap;color:#e5dccd;background:#090b0f;padding:10px;border-radius:6px">' + _html.escape(f["text"]) + '</pre>'
439
+ '<div style="color:#c9bdab">Why: ' + _html.escape(f["why"]) + '</div>'
440
+ '<div style="color:#84d39b">Push back: ' + _html.escape(f["tip"]) + '</div></div>')
441
+ return "".join(cards)
442
+
443
+
444
+ def launch_blocks_fallback():
445
+ if gr is None:
446
+ raise RuntimeError("Gradio is required unless LEASE_LENS_MOCK=1 is used.")
447
+ with gr.Blocks(css=CSS_FALLBACK, title="Lease Lens") as demo:
448
+ gr.HTML('<div id="hdr"><h1>Lease Lens</h1>'
449
+ '<p>Read the lease before it reads you. A fine-tuned 3B legal model scores risk, flags verbatim clauses, highlights evidence, and drafts pushback.</p>'
450
+ '<span class="chip">3B fine-tune</span><span class="chip">+242% F1 vs base</span>'
451
+ '<span class="chip">SEC-filed examples</span><span class="chip">GGUF / llama.cpp</span></div>')
452
+ with gr.Row():
453
+ ex = gr.Dropdown(choices=list(EXAMPLES.keys()), value=DEFAULT_EXAMPLE, label="Load a real filing or sample")
454
+ up = gr.File(label="...or upload your own .txt contract", file_types=[".txt"], type="filepath")
455
+ src_banner = gr.HTML(value=_source_banner_html(DEFAULT_EXAMPLE))
456
+ inp = gr.Textbox(value=EXAMPLES[DEFAULT_EXAMPLE], lines=10, max_lines=20, label="Contract text")
457
+ with gr.Row(elem_id="go_row"):
458
+ btn = gr.Button("Analyze contract", variant="primary", scale=4)
459
+ clear_btn = gr.Button("Clear", variant="secondary", scale=1)
460
+ st = gr.State("[]")
461
+ with gr.Row():
462
+ out_cards = gr.HTML()
463
+ out_doc = gr.HTML()
464
+ with gr.Accordion("Negotiation Letter", open=False):
465
+ email_btn = gr.Button("Draft pushback")
466
+ email_out = gr.Textbox(lines=12, label="Draft for review", show_copy_button=True)
467
+
468
+ def load_example(name):
469
+ payload = get_example_payload(name)
470
+ return payload["text"], payload["source_banner_html"]
471
+
472
+ def load_file(path):
473
+ if not path:
474
+ return "", ""
475
+ with open(path, "r", errors="ignore") as fh:
476
+ return fh.read(), ""
477
+
478
+ def analyze_blocks(text):
479
+ data = analyze_contract_payload(text)
480
+ return _render_blocks_results(data), data.get("highlighted_html", ""), json.dumps(data.get("findings", []))
481
+
482
+ def draft_blocks(state_json):
483
+ return draft_email_payload(state_json).get("email", "")
484
+
485
+ ex.change(load_example, ex, [inp, src_banner])
486
+ up.upload(load_file, up, [inp, src_banner])
487
+ btn.click(analyze_blocks, inp, [out_cards, out_doc, st])
488
+ email_btn.click(draft_blocks, st, email_out)
489
+ clear_btn.click(lambda: ("", "", "", "", "[]"), None, [inp, src_banner, out_cards, out_doc, st])
490
+
491
+ demo.queue().launch(ssr_mode=False, show_error=True)
492
+
493
+
494
+ def launch_stdlib_mock_server():
495
+ import mimetypes
496
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
497
+ from urllib.parse import parse_qs, urlparse
498
+
499
+ class Handler(BaseHTTPRequestHandler):
500
+ def _json(self, payload, status=200):
501
+ body = json.dumps(payload).encode("utf-8")
502
+ self.send_response(status)
503
+ self.send_header("Content-Type", "application/json; charset=utf-8")
504
+ self.send_header("Content-Length", str(len(body)))
505
+ self.end_headers()
506
+ self.wfile.write(body)
507
+
508
+ def _read_json(self):
509
+ length = int(self.headers.get("Content-Length", "0"))
510
+ raw = self.rfile.read(length).decode("utf-8") if length else "{}"
511
+ return json.loads(raw or "{}")
512
+
513
+ def do_GET(self):
514
+ parsed = urlparse(self.path)
515
+ if parsed.path == "/":
516
+ body = _index_html().encode("utf-8")
517
+ self.send_response(200)
518
+ self.send_header("Content-Type", "text/html; charset=utf-8")
519
+ self.send_header("Content-Length", str(len(body)))
520
+ self.end_headers()
521
+ self.wfile.write(body)
522
+ return
523
+ if parsed.path == "/api/get_example":
524
+ name = parse_qs(parsed.query).get("name", [DEFAULT_EXAMPLE])[0]
525
+ self._json(get_example_payload(name))
526
+ return
527
+ if parsed.path.startswith("/static/"):
528
+ rel = parsed.path.replace("/static/", "", 1)
529
+ target = (STATIC_DIR / rel).resolve()
530
+ if not target.exists() or STATIC_DIR.resolve() not in target.parents:
531
+ self.send_error(404)
532
+ return
533
+ body = target.read_bytes()
534
+ self.send_response(200)
535
+ self.send_header("Content-Type", mimetypes.guess_type(str(target))[0] or "application/octet-stream")
536
+ self.send_header("Content-Length", str(len(body)))
537
+ self.end_headers()
538
+ self.wfile.write(body)
539
+ return
540
+ self.send_error(404)
541
+
542
+ def do_POST(self):
543
+ if self.path == "/api/analyze_contract":
544
+ self._json(analyze_contract_payload(self._read_json().get("text", "")))
545
+ return
546
+ if self.path == "/api/draft_email":
547
+ self._json(draft_email_payload(self._read_json().get("state_json", "[]")))
548
+ return
549
+ self.send_error(404)
550
+
551
+ host = os.getenv("HOST", "127.0.0.1")
552
+ port = int(os.getenv("PORT", "7860"))
553
+ print(f"Lease Lens mock UI running on http://{host}:{port}")
554
+ ThreadingHTTPServer((host, port), Handler).serve_forever()
555
+
556
+
557
+ def launch():
558
+ if gr is None:
559
+ if MOCK_MODE:
560
+ return launch_stdlib_mock_server()
561
+ raise RuntimeError("Gradio is not installed. Hugging Face Spaces provides it via sdk_version.")
562
+ return launch_server()
563
+
564
+
565
+ if __name__ == "__main__":
566
+ launch()
docs/codex-build-log.md CHANGED
@@ -21,6 +21,12 @@ Prepare Lease Lens for the Build Small Hackathon with maximum near-deadline leve
21
  evidence and a short judge path.
22
  - Added this build log as the public provenance artifact for reviewers once the
23
  repo is published.
 
 
 
 
 
 
24
  - Updated the Gradio app to default to a real SEC-filed lease and show the SEC
25
  provenance banner on first load.
26
  - Kept model loading, prompting, scoring, extraction guards, and generation
@@ -48,16 +54,6 @@ Expected:
48
  - private is `False`;
49
  - SHA matches the latest pushed Space commit.
50
 
51
- ## Codex Attribution Policy
52
-
53
- Use commit messages with this trailer:
54
-
55
- ```text
56
- Co-authored-by: OpenAI Codex <codex@openai.com>
57
- ```
58
-
59
- This keeps the public Git history explicit for the OpenAI Codex Track judge.
60
-
61
  ## Local Codex-Attributed Commits
62
 
63
  - `df9f20d35448693d6307dc8be275963e4b90fbc5` - prepared the hackathon submission package, README proof, and app default.
 
21
  evidence and a short judge path.
22
  - Added this build log as the public provenance artifact for reviewers once the
23
  repo is published.
24
+ - Replaced the visible stock Gradio Blocks interface with a custom redline legal
25
+ evidence desk served through `gradio.Server`, with a styled Blocks fallback.
26
+ - Added public frontend source files: `index.html`, `static/app.css`,
27
+ `static/app.js`, and `static/lease-lens-mark.svg`.
28
+ - Added REST fallback routes beside the Gradio client APIs so the custom
29
+ frontend still works if a browser blocks the CDN JS client.
30
  - Updated the Gradio app to default to a real SEC-filed lease and show the SEC
31
  provenance banner on first load.
32
  - Kept model loading, prompting, scoring, extraction guards, and generation
 
54
  - private is `False`;
55
  - SHA matches the latest pushed Space commit.
56
 
 
 
 
 
 
 
 
 
 
 
57
  ## Local Codex-Attributed Commits
58
 
59
  - `df9f20d35448693d6307dc8be275963e4b90fbc5` - prepared the hackathon submission package, README proof, and app default.
index.html ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Lease Lens</title>
7
+ <link rel="icon" href="/static/lease-lens-mark.svg" type="image/svg+xml">
8
+ <link rel="stylesheet" href="/static/app.css">
9
+ </head>
10
+ <body>
11
+ <script id="lease-lens-bootstrap" type="application/json">__LEASE_LENS_BOOTSTRAP__</script>
12
+
13
+ <main class="desk-shell">
14
+ <section class="hero-grid" aria-labelledby="app-title">
15
+ <div class="hero-copy">
16
+ <div class="brand-row">
17
+ <img src="/static/lease-lens-mark.svg" alt="" width="42" height="42">
18
+ <span>Lease Lens</span>
19
+ </div>
20
+ <p class="eyebrow">Redline evidence desk · 3B legal model</p>
21
+ <h1 id="app-title">Read the lease before it reads you.</h1>
22
+ <p class="lede">Load a real SEC-filed lease, run a grounded clause scan, and turn the flags into a plain-English pushback letter. No external LLM API is called.</p>
23
+ <div id="proofChips" class="proof-chips" aria-label="Project proof points"></div>
24
+ </div>
25
+
26
+ <aside class="quickstart" aria-label="Judge quickstart">
27
+ <div class="quickstart-title">Judge path</div>
28
+ <ol>
29
+ <li><span>01</span><b>Load real filing</b></li>
30
+ <li><span>02</span><b>Analyze contract</b></li>
31
+ <li><span>03</span><b>Draft pushback</b></li>
32
+ </ol>
33
+ </aside>
34
+ </section>
35
+
36
+ <section class="workspace-grid" aria-label="Contract analysis workspace">
37
+ <section class="intake-panel panel">
38
+ <div class="panel-head">
39
+ <div>
40
+ <p class="panel-kicker">Input</p>
41
+ <h2>Contract docket</h2>
42
+ </div>
43
+ <button id="clearBtn" class="ghost-btn" type="button">Clear</button>
44
+ </div>
45
+
46
+ <label class="field-label" for="exampleSelect">Real filing or sample</label>
47
+ <select id="exampleSelect" class="select-control"></select>
48
+
49
+ <div id="sourceBanner" class="source-slot" aria-live="polite"></div>
50
+
51
+ <div class="upload-row">
52
+ <label class="file-btn" for="fileInput">Upload .txt</label>
53
+ <input id="fileInput" type="file" accept=".txt,text/plain">
54
+ <span id="fileName">or edit the docket text below</span>
55
+ </div>
56
+
57
+ <label class="field-label" for="contractText">Contract text</label>
58
+ <textarea id="contractText" spellcheck="false"></textarea>
59
+
60
+ <div class="action-row">
61
+ <button id="analyzeBtn" class="primary-btn" type="button">
62
+ <span class="btn-main">Analyze contract</span>
63
+ <span class="btn-sub">verbatim flags + risk score</span>
64
+ </button>
65
+ <div id="runState" class="run-state" aria-live="polite">Ready on ZeroGPU</div>
66
+ </div>
67
+ </section>
68
+
69
+ <section class="result-panel panel">
70
+ <div id="emptyState" class="empty-state">
71
+ <div class="stamp">Ready</div>
72
+ <h2>Evidence appears here.</h2>
73
+ <p>Run the default SEC lease first. A good result shows a score, verbatim flagged clauses, highlighted source text, and a negotiation letter draft.</p>
74
+ </div>
75
+
76
+ <div id="results" class="results hidden">
77
+ <div class="docket-summary">
78
+ <div id="scoreSeal" class="score-seal clean">
79
+ <span id="scoreValue">0</span>
80
+ <small>/100</small>
81
+ </div>
82
+ <div>
83
+ <p class="panel-kicker">Risk docket</p>
84
+ <h2 id="verdictText">Looks clean</h2>
85
+ <p id="summaryText" class="summary-text"></p>
86
+ </div>
87
+ </div>
88
+
89
+ <div class="result-split">
90
+ <div>
91
+ <div class="section-label">Clause flags</div>
92
+ <div id="findingCards" class="finding-stack"></div>
93
+ <div id="coverageNote" class="coverage-note"></div>
94
+ </div>
95
+ <div>
96
+ <div class="section-label">Source text</div>
97
+ <div id="highlightedDoc" class="document-view"></div>
98
+ </div>
99
+ </div>
100
+
101
+ <div class="letter-panel">
102
+ <div class="letter-head">
103
+ <div>
104
+ <p class="panel-kicker">Negotiation Letter</p>
105
+ <h2>Draft pushback</h2>
106
+ </div>
107
+ <button id="emailBtn" class="secondary-btn" type="button">Draft email</button>
108
+ </div>
109
+ <textarea id="emailOut" readonly placeholder="Run analysis, then draft an email from the flags."></textarea>
110
+ </div>
111
+ </div>
112
+ </section>
113
+ </section>
114
+ </main>
115
+
116
+ <script type="module" src="/static/app.js"></script>
117
+ </body>
118
+ </html>
static/app.css ADDED
@@ -0,0 +1,616 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: dark;
3
+ --carbon: #08090c;
4
+ --carbon-2: #10141a;
5
+ --carbon-3: #151b22;
6
+ --paper: #f2e6d1;
7
+ --paper-dim: #c9bdab;
8
+ --muted: #8d826f;
9
+ --line: #4a3f31;
10
+ --brass: #c2934a;
11
+ --brass-soft: rgba(194, 147, 74, 0.16);
12
+ --redline: #c83f3f;
13
+ --redline-soft: rgba(200, 63, 63, 0.15);
14
+ --cyan: #62d6d2;
15
+ --cyan-soft: rgba(98, 214, 210, 0.12);
16
+ --green: #77c889;
17
+ --green-soft: rgba(119, 200, 137, 0.12);
18
+ --shadow: 0 22px 60px rgba(0, 0, 0, 0.42);
19
+ }
20
+
21
+ * {
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ html {
26
+ min-height: 100%;
27
+ background: var(--carbon);
28
+ }
29
+
30
+ body {
31
+ margin: 0;
32
+ min-height: 100%;
33
+ color: var(--paper);
34
+ font-family: Aptos, "Segoe UI", sans-serif;
35
+ background:
36
+ linear-gradient(90deg, rgba(194,147,74,0.04) 1px, transparent 1px) 0 0 / 44px 44px,
37
+ linear-gradient(rgba(194,147,74,0.035) 1px, transparent 1px) 0 0 / 44px 44px,
38
+ radial-gradient(circle at 50% 0%, rgba(98,214,210,0.08), transparent 36rem),
39
+ var(--carbon);
40
+ }
41
+
42
+ button,
43
+ select,
44
+ textarea {
45
+ font: inherit;
46
+ }
47
+
48
+ button {
49
+ cursor: pointer;
50
+ }
51
+
52
+ a {
53
+ color: var(--cyan);
54
+ }
55
+
56
+ .desk-shell {
57
+ width: min(1480px, calc(100% - 32px));
58
+ margin: 0 auto;
59
+ padding: 24px 0 34px;
60
+ }
61
+
62
+ .hero-grid,
63
+ .workspace-grid {
64
+ display: grid;
65
+ gap: 18px;
66
+ }
67
+
68
+ .hero-grid {
69
+ grid-template-columns: minmax(0, 1fr) 360px;
70
+ align-items: stretch;
71
+ margin-bottom: 18px;
72
+ }
73
+
74
+ .hero-copy,
75
+ .quickstart,
76
+ .panel {
77
+ border: 1px solid var(--line);
78
+ border-radius: 8px;
79
+ background:
80
+ linear-gradient(135deg, rgba(255,255,255,0.035), transparent 30%),
81
+ rgba(16, 20, 26, 0.92);
82
+ box-shadow: var(--shadow);
83
+ }
84
+
85
+ .hero-copy {
86
+ position: relative;
87
+ overflow: hidden;
88
+ padding: 28px;
89
+ min-height: 270px;
90
+ }
91
+
92
+ .hero-copy::after {
93
+ content: "VERBATIM";
94
+ position: absolute;
95
+ right: 26px;
96
+ bottom: 12px;
97
+ color: rgba(194,147,74,0.1);
98
+ font: 700 64px/1 Georgia, serif;
99
+ letter-spacing: 0;
100
+ pointer-events: none;
101
+ }
102
+
103
+ .brand-row {
104
+ display: inline-flex;
105
+ align-items: center;
106
+ gap: 10px;
107
+ color: var(--paper);
108
+ font: 700 18px/1 Georgia, serif;
109
+ margin-bottom: 28px;
110
+ }
111
+
112
+ .brand-row img {
113
+ display: block;
114
+ }
115
+
116
+ .eyebrow,
117
+ .panel-kicker,
118
+ .section-label {
119
+ margin: 0;
120
+ color: var(--brass);
121
+ font-size: 12px;
122
+ line-height: 1.3;
123
+ letter-spacing: 0.08em;
124
+ text-transform: uppercase;
125
+ }
126
+
127
+ h1,
128
+ h2 {
129
+ margin: 0;
130
+ font-family: Georgia, "Times New Roman", serif;
131
+ letter-spacing: 0;
132
+ }
133
+
134
+ h1 {
135
+ max-width: 820px;
136
+ font-size: clamp(42px, 6vw, 88px);
137
+ line-height: 0.92;
138
+ }
139
+
140
+ h2 {
141
+ font-size: 22px;
142
+ line-height: 1.15;
143
+ }
144
+
145
+ .lede {
146
+ max-width: 760px;
147
+ margin: 18px 0 20px;
148
+ color: var(--paper-dim);
149
+ font-size: 17px;
150
+ line-height: 1.6;
151
+ }
152
+
153
+ .proof-chips {
154
+ position: relative;
155
+ z-index: 1;
156
+ display: flex;
157
+ flex-wrap: wrap;
158
+ gap: 8px;
159
+ }
160
+
161
+ .proof-chips span,
162
+ .chip {
163
+ display: inline-flex;
164
+ align-items: center;
165
+ min-height: 30px;
166
+ padding: 6px 10px;
167
+ border: 1px solid rgba(98, 214, 210, 0.35);
168
+ border-radius: 999px;
169
+ color: var(--cyan);
170
+ background: var(--cyan-soft);
171
+ font: 600 12px/1.2 "Cascadia Mono", "SFMono-Regular", monospace;
172
+ }
173
+
174
+ .quickstart {
175
+ padding: 22px;
176
+ }
177
+
178
+ .quickstart-title {
179
+ color: var(--paper-dim);
180
+ font-size: 13px;
181
+ margin-bottom: 14px;
182
+ }
183
+
184
+ .quickstart ol {
185
+ display: grid;
186
+ gap: 11px;
187
+ padding: 0;
188
+ margin: 0;
189
+ list-style: none;
190
+ }
191
+
192
+ .quickstart li {
193
+ display: grid;
194
+ grid-template-columns: 52px 1fr;
195
+ align-items: center;
196
+ min-height: 52px;
197
+ border: 1px solid rgba(194,147,74,0.32);
198
+ border-radius: 8px;
199
+ background: rgba(8, 9, 12, 0.48);
200
+ }
201
+
202
+ .quickstart li span {
203
+ color: var(--redline);
204
+ text-align: center;
205
+ font: 700 13px/1 "Cascadia Mono", monospace;
206
+ }
207
+
208
+ .workspace-grid {
209
+ grid-template-columns: minmax(360px, 0.92fr) minmax(0, 1.28fr);
210
+ align-items: start;
211
+ }
212
+
213
+ .panel {
214
+ padding: 20px;
215
+ }
216
+
217
+ .panel-head,
218
+ .letter-head,
219
+ .action-row {
220
+ display: flex;
221
+ gap: 12px;
222
+ align-items: center;
223
+ justify-content: space-between;
224
+ }
225
+
226
+ .field-label {
227
+ display: block;
228
+ margin: 18px 0 8px;
229
+ color: var(--paper-dim);
230
+ font-size: 13px;
231
+ }
232
+
233
+ .select-control,
234
+ textarea {
235
+ width: 100%;
236
+ border: 1px solid var(--line);
237
+ border-radius: 8px;
238
+ color: var(--paper);
239
+ background: #090b0f;
240
+ outline: none;
241
+ }
242
+
243
+ .select-control {
244
+ height: 42px;
245
+ padding: 0 12px;
246
+ }
247
+
248
+ textarea {
249
+ resize: vertical;
250
+ min-height: 350px;
251
+ padding: 14px;
252
+ line-height: 1.55;
253
+ font-family: Georgia, "Times New Roman", serif;
254
+ font-size: 14px;
255
+ }
256
+
257
+ textarea:focus,
258
+ .select-control:focus {
259
+ border-color: var(--cyan);
260
+ box-shadow: 0 0 0 3px rgba(98,214,210,0.12);
261
+ }
262
+
263
+ .source-slot {
264
+ min-height: 0;
265
+ }
266
+
267
+ .source-banner {
268
+ margin-top: 12px;
269
+ padding: 10px 12px;
270
+ border: 1px solid rgba(98,214,210,0.32);
271
+ border-radius: 8px;
272
+ color: var(--paper-dim);
273
+ background: var(--cyan-soft);
274
+ font-size: 13px;
275
+ line-height: 1.45;
276
+ }
277
+
278
+ .source-dot {
279
+ display: inline-flex;
280
+ align-items: center;
281
+ justify-content: center;
282
+ min-width: 36px;
283
+ height: 22px;
284
+ margin-right: 8px;
285
+ border-radius: 999px;
286
+ color: #061112;
287
+ background: var(--cyan);
288
+ font: 800 11px/1 "Cascadia Mono", monospace;
289
+ }
290
+
291
+ .upload-row {
292
+ display: flex;
293
+ flex-wrap: wrap;
294
+ gap: 10px;
295
+ align-items: center;
296
+ margin: 14px 0 2px;
297
+ color: var(--muted);
298
+ font-size: 13px;
299
+ }
300
+
301
+ .upload-row input {
302
+ position: absolute;
303
+ width: 1px;
304
+ height: 1px;
305
+ opacity: 0;
306
+ pointer-events: none;
307
+ }
308
+
309
+ .file-btn,
310
+ .ghost-btn,
311
+ .secondary-btn,
312
+ .primary-btn {
313
+ border-radius: 8px;
314
+ border: 1px solid transparent;
315
+ transition: transform .16s ease, border-color .16s ease, background .16s ease;
316
+ }
317
+
318
+ .file-btn,
319
+ .ghost-btn,
320
+ .secondary-btn {
321
+ color: var(--paper);
322
+ background: rgba(255,255,255,0.04);
323
+ border-color: var(--line);
324
+ }
325
+
326
+ .file-btn,
327
+ .ghost-btn {
328
+ padding: 9px 12px;
329
+ }
330
+
331
+ .secondary-btn {
332
+ padding: 11px 14px;
333
+ color: var(--cyan);
334
+ border-color: rgba(98,214,210,0.35);
335
+ }
336
+
337
+ .primary-btn {
338
+ display: grid;
339
+ gap: 2px;
340
+ min-width: 210px;
341
+ padding: 13px 16px;
342
+ color: #130d08;
343
+ background: var(--brass);
344
+ border-color: #e6c37d;
345
+ text-align: left;
346
+ }
347
+
348
+ .btn-main {
349
+ font-weight: 800;
350
+ }
351
+
352
+ .btn-sub {
353
+ font-size: 11px;
354
+ opacity: .75;
355
+ }
356
+
357
+ button:hover,
358
+ .file-btn:hover {
359
+ transform: translateY(-1px);
360
+ }
361
+
362
+ button:disabled {
363
+ cursor: wait;
364
+ opacity: .68;
365
+ transform: none;
366
+ }
367
+
368
+ .run-state {
369
+ color: var(--muted);
370
+ font: 600 12px/1.4 "Cascadia Mono", monospace;
371
+ }
372
+
373
+ .empty-state {
374
+ display: grid;
375
+ place-items: center;
376
+ min-height: 560px;
377
+ text-align: center;
378
+ color: var(--paper-dim);
379
+ border: 1px dashed rgba(194,147,74,0.28);
380
+ border-radius: 8px;
381
+ padding: 32px;
382
+ }
383
+
384
+ .empty-state h2 {
385
+ margin-top: 14px;
386
+ color: var(--paper);
387
+ }
388
+
389
+ .empty-state p {
390
+ max-width: 420px;
391
+ line-height: 1.6;
392
+ }
393
+
394
+ .stamp {
395
+ display: grid;
396
+ place-items: center;
397
+ width: 116px;
398
+ aspect-ratio: 1;
399
+ border: 2px solid var(--brass);
400
+ border-radius: 50%;
401
+ color: var(--brass);
402
+ font: 800 14px/1 "Cascadia Mono", monospace;
403
+ text-transform: uppercase;
404
+ transform: rotate(-8deg);
405
+ }
406
+
407
+ .hidden {
408
+ display: none !important;
409
+ }
410
+
411
+ .docket-summary {
412
+ display: grid;
413
+ grid-template-columns: 112px 1fr;
414
+ gap: 18px;
415
+ align-items: center;
416
+ margin-bottom: 18px;
417
+ }
418
+
419
+ .score-seal {
420
+ display: grid;
421
+ place-items: center;
422
+ width: 112px;
423
+ aspect-ratio: 1;
424
+ border-radius: 50%;
425
+ border: 2px solid var(--green);
426
+ color: var(--green);
427
+ background: var(--green-soft);
428
+ }
429
+
430
+ .score-seal.high {
431
+ color: var(--redline);
432
+ border-color: var(--redline);
433
+ background: var(--redline-soft);
434
+ }
435
+
436
+ .score-seal.med {
437
+ color: var(--brass);
438
+ border-color: var(--brass);
439
+ background: var(--brass-soft);
440
+ }
441
+
442
+ .score-seal span {
443
+ font: 700 42px/1 Georgia, serif;
444
+ }
445
+
446
+ .score-seal small {
447
+ margin-top: -20px;
448
+ color: var(--paper-dim);
449
+ }
450
+
451
+ .summary-text,
452
+ .coverage-note {
453
+ color: var(--paper-dim);
454
+ line-height: 1.5;
455
+ }
456
+
457
+ .result-split {
458
+ display: grid;
459
+ grid-template-columns: minmax(280px, .92fr) minmax(320px, 1.08fr);
460
+ gap: 16px;
461
+ }
462
+
463
+ .section-label {
464
+ margin-bottom: 10px;
465
+ }
466
+
467
+ .finding-stack {
468
+ display: grid;
469
+ gap: 10px;
470
+ }
471
+
472
+ .finding-card {
473
+ border: 1px solid var(--line);
474
+ border-left: 4px solid var(--brass);
475
+ border-radius: 8px;
476
+ background: rgba(8, 9, 12, 0.52);
477
+ padding: 13px;
478
+ }
479
+
480
+ .finding-card.high {
481
+ border-left-color: var(--redline);
482
+ }
483
+
484
+ .finding-card h3 {
485
+ display: flex;
486
+ justify-content: space-between;
487
+ gap: 10px;
488
+ margin: 0 0 10px;
489
+ color: var(--paper);
490
+ font-size: 15px;
491
+ }
492
+
493
+ .risk-pill {
494
+ flex: 0 0 auto;
495
+ align-self: start;
496
+ border-radius: 999px;
497
+ padding: 4px 8px;
498
+ color: var(--brass);
499
+ border: 1px solid currentColor;
500
+ font: 800 10px/1 "Cascadia Mono", monospace;
501
+ text-transform: uppercase;
502
+ }
503
+
504
+ .finding-card.high .risk-pill {
505
+ color: var(--redline);
506
+ }
507
+
508
+ .quote {
509
+ margin: 0 0 10px;
510
+ padding: 10px;
511
+ border-radius: 6px;
512
+ color: #efe3d0;
513
+ background: #07080b;
514
+ white-space: pre-wrap;
515
+ font: 13px/1.55 "Cascadia Mono", "SFMono-Regular", monospace;
516
+ }
517
+
518
+ .finding-card p {
519
+ margin: 7px 0 0;
520
+ color: var(--paper-dim);
521
+ line-height: 1.45;
522
+ font-size: 13px;
523
+ }
524
+
525
+ .finding-card p b {
526
+ color: var(--cyan);
527
+ }
528
+
529
+ .document-view {
530
+ min-height: 540px;
531
+ max-height: 680px;
532
+ overflow: auto;
533
+ border: 1px solid var(--line);
534
+ border-radius: 8px;
535
+ background: #0a0b0e;
536
+ }
537
+
538
+ .contract-page {
539
+ white-space: pre-wrap;
540
+ color: #eadfce;
541
+ padding: 18px;
542
+ font: 14px/1.75 Georgia, "Times New Roman", serif;
543
+ }
544
+
545
+ .contract-page mark {
546
+ background: #f0ce73;
547
+ color: #11100e;
548
+ border-radius: 3px;
549
+ padding: 0 2px;
550
+ }
551
+
552
+ .coverage-note {
553
+ margin-top: 12px;
554
+ font-size: 13px;
555
+ }
556
+
557
+ .letter-panel {
558
+ margin-top: 18px;
559
+ border: 1px solid var(--line);
560
+ border-radius: 8px;
561
+ background: rgba(242,230,209,0.035);
562
+ padding: 16px;
563
+ }
564
+
565
+ .letter-panel textarea {
566
+ min-height: 210px;
567
+ margin-top: 14px;
568
+ background: #11100d;
569
+ color: var(--paper);
570
+ }
571
+
572
+ @media (max-width: 1120px) {
573
+ .hero-grid,
574
+ .workspace-grid,
575
+ .result-split {
576
+ grid-template-columns: 1fr;
577
+ }
578
+
579
+ .empty-state {
580
+ min-height: 360px;
581
+ }
582
+ }
583
+
584
+ @media (max-width: 620px) {
585
+ .desk-shell {
586
+ width: min(100% - 18px, 1480px);
587
+ padding-top: 10px;
588
+ }
589
+
590
+ .hero-copy,
591
+ .quickstart,
592
+ .panel {
593
+ padding: 14px;
594
+ }
595
+
596
+ h1 {
597
+ font-size: 42px;
598
+ }
599
+
600
+ .panel-head,
601
+ .letter-head,
602
+ .action-row,
603
+ .docket-summary {
604
+ align-items: stretch;
605
+ grid-template-columns: 1fr;
606
+ flex-direction: column;
607
+ }
608
+
609
+ .score-seal {
610
+ width: 96px;
611
+ }
612
+
613
+ textarea {
614
+ min-height: 300px;
615
+ }
616
+ }
static/app.js ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const bootstrap = JSON.parse(document.getElementById("lease-lens-bootstrap").textContent);
2
+
3
+ const els = {
4
+ proofChips: document.getElementById("proofChips"),
5
+ exampleSelect: document.getElementById("exampleSelect"),
6
+ sourceBanner: document.getElementById("sourceBanner"),
7
+ fileInput: document.getElementById("fileInput"),
8
+ fileName: document.getElementById("fileName"),
9
+ contractText: document.getElementById("contractText"),
10
+ analyzeBtn: document.getElementById("analyzeBtn"),
11
+ clearBtn: document.getElementById("clearBtn"),
12
+ runState: document.getElementById("runState"),
13
+ emptyState: document.getElementById("emptyState"),
14
+ results: document.getElementById("results"),
15
+ scoreSeal: document.getElementById("scoreSeal"),
16
+ scoreValue: document.getElementById("scoreValue"),
17
+ verdictText: document.getElementById("verdictText"),
18
+ summaryText: document.getElementById("summaryText"),
19
+ findingCards: document.getElementById("findingCards"),
20
+ coverageNote: document.getElementById("coverageNote"),
21
+ highlightedDoc: document.getElementById("highlightedDoc"),
22
+ emailBtn: document.getElementById("emailBtn"),
23
+ emailOut: document.getElementById("emailOut"),
24
+ };
25
+
26
+ let client = null;
27
+ let lastFindings = [];
28
+
29
+ function escapeHtml(value) {
30
+ return String(value ?? "")
31
+ .replaceAll("&", "&amp;")
32
+ .replaceAll("<", "&lt;")
33
+ .replaceAll(">", "&gt;")
34
+ .replaceAll('"', "&quot;")
35
+ .replaceAll("'", "&#039;");
36
+ }
37
+
38
+ function unwrap(result) {
39
+ if (result && Array.isArray(result.data)) return result.data[0];
40
+ return result;
41
+ }
42
+
43
+ async function connectClient() {
44
+ try {
45
+ const mod = await import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js");
46
+ client = await mod.Client.connect(window.location.origin);
47
+ els.runState.textContent = bootstrap.mock_mode ? "Mock preview active" : "Ready on ZeroGPU";
48
+ } catch (error) {
49
+ client = null;
50
+ els.runState.textContent = bootstrap.mock_mode ? "Mock REST fallback" : "Client loading failed";
51
+ }
52
+ }
53
+
54
+ async function predict(apiName, payload, restPath, method = "POST") {
55
+ if (client) {
56
+ const result = await client.predict(apiName, payload);
57
+ return unwrap(result);
58
+ }
59
+ if (method === "GET") {
60
+ const params = new URLSearchParams(payload);
61
+ const response = await fetch(`${restPath}?${params}`);
62
+ return response.json();
63
+ }
64
+ const response = await fetch(restPath, {
65
+ method,
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify(payload),
68
+ });
69
+ return response.json();
70
+ }
71
+
72
+ function setBusy(isBusy, label = "Running redline scan") {
73
+ els.analyzeBtn.disabled = isBusy;
74
+ els.emailBtn.disabled = isBusy;
75
+ els.runState.textContent = isBusy ? label : (bootstrap.mock_mode ? "Mock preview active" : "Ready on ZeroGPU");
76
+ }
77
+
78
+ function populateProof() {
79
+ els.proofChips.innerHTML = "";
80
+ for (const chip of bootstrap.proof_chips || []) {
81
+ const el = document.createElement("span");
82
+ el.textContent = chip;
83
+ els.proofChips.appendChild(el);
84
+ }
85
+ }
86
+
87
+ function populateExamples() {
88
+ els.exampleSelect.innerHTML = "";
89
+ for (const name of bootstrap.examples || []) {
90
+ const option = document.createElement("option");
91
+ option.value = name;
92
+ option.textContent = name;
93
+ els.exampleSelect.appendChild(option);
94
+ }
95
+ els.exampleSelect.value = bootstrap.default_example;
96
+ }
97
+
98
+ async function loadExample(name) {
99
+ setBusy(true, "Loading docket");
100
+ try {
101
+ const payload = await predict("/get_example", { name }, "/api/get_example", "GET");
102
+ els.contractText.value = payload.text || "";
103
+ els.sourceBanner.innerHTML = payload.source_banner_html || '<div class="source-banner"><span class="source-dot">SMP</span>Synthetic teaching sample. Upload a .txt contract or choose a real SEC filing.</div>';
104
+ els.fileName.textContent = payload.is_real ? "real public filing loaded" : "sample loaded";
105
+ } finally {
106
+ setBusy(false);
107
+ }
108
+ }
109
+
110
+ function renderEmpty(message) {
111
+ els.emptyState.classList.remove("hidden");
112
+ els.results.classList.add("hidden");
113
+ if (message) {
114
+ els.emptyState.querySelector("p").textContent = message;
115
+ }
116
+ }
117
+
118
+ function scoreClass(score, highCount, flagCount) {
119
+ if (highCount > 0) return "high";
120
+ if (flagCount > 0 || score > 0) return "med";
121
+ return "clean";
122
+ }
123
+
124
+ function renderFindings(findings) {
125
+ if (!findings.length) {
126
+ els.findingCards.innerHTML = '<div class="finding-card"><h3>No risky clauses flagged <span class="risk-pill">clear</span></h3><p>The checked clause categories did not produce a grounded risky-clause flag.</p></div>';
127
+ return;
128
+ }
129
+ const sorted = [...findings].sort((a, b) => (a.risk === "high" ? -1 : 1) - (b.risk === "high" ? -1 : 1));
130
+ els.findingCards.innerHTML = sorted.map((finding) => `
131
+ <article class="finding-card ${finding.risk === "high" ? "high" : ""}">
132
+ <h3>${escapeHtml(finding.label)} <span class="risk-pill">${escapeHtml(finding.risk)}</span></h3>
133
+ <pre class="quote">${escapeHtml(finding.text)}</pre>
134
+ <p><b>Why it matters:</b> ${escapeHtml(finding.why)}</p>
135
+ <p><b>Push back:</b> ${escapeHtml(finding.tip)}</p>
136
+ </article>
137
+ `).join("");
138
+ }
139
+
140
+ function renderResults(data) {
141
+ if (!data || data.status === "empty") {
142
+ renderEmpty(data?.message || "Paste or pick a contract first.");
143
+ return;
144
+ }
145
+ if (data.status === "error") {
146
+ renderEmpty(data.message || "Analysis failed. Try again shortly.");
147
+ return;
148
+ }
149
+
150
+ lastFindings = data.findings || [];
151
+ els.emptyState.classList.add("hidden");
152
+ els.results.classList.remove("hidden");
153
+
154
+ const sealClass = scoreClass(data.score || 0, data.high_count || 0, data.flag_count || 0);
155
+ els.scoreSeal.className = `score-seal ${sealClass}`;
156
+ els.scoreValue.textContent = data.score ?? 0;
157
+ els.verdictText.textContent = data.verdict || "Review complete";
158
+ els.summaryText.textContent = `${data.flag_count || 0} clauses flagged (${data.high_count || 0} high-risk) of ${data.checked_count || 0} checked.`;
159
+ renderFindings(lastFindings);
160
+
161
+ const skipped = data.skipped?.length ? `Skipped because keywords were absent: ${data.skipped.join(", ")}.` : "";
162
+ const note = data.coverage_note ? `${data.coverage_note} ` : "";
163
+ els.coverageNote.textContent = `${note}${skipped} ${data.disclaimer || ""}`.trim();
164
+ els.highlightedDoc.innerHTML = data.highlighted_html || '<div class="contract-page">No source text available.</div>';
165
+ els.emailOut.value = "";
166
+ }
167
+
168
+ async function analyze() {
169
+ const text = els.contractText.value.trim();
170
+ setBusy(true, "Running batched checks");
171
+ try {
172
+ const data = await predict("/analyze_contract", { text }, "/api/analyze_contract");
173
+ renderResults(data);
174
+ } catch (error) {
175
+ renderEmpty(`Analysis failed: ${error.message}`);
176
+ } finally {
177
+ setBusy(false);
178
+ }
179
+ }
180
+
181
+ async function draftEmail() {
182
+ if (!lastFindings.length) {
183
+ els.emailOut.value = "Run an analysis first - then I can draft the email from the flagged clauses.";
184
+ return;
185
+ }
186
+ setBusy(true, "Drafting pushback");
187
+ try {
188
+ const data = await predict("/draft_email", { state_json: JSON.stringify(lastFindings) }, "/api/draft_email");
189
+ els.emailOut.value = data.email || "";
190
+ } catch (error) {
191
+ els.emailOut.value = `Draft failed: ${error.message}`;
192
+ } finally {
193
+ setBusy(false);
194
+ }
195
+ }
196
+
197
+ function bindEvents() {
198
+ els.exampleSelect.addEventListener("change", () => loadExample(els.exampleSelect.value));
199
+ els.analyzeBtn.addEventListener("click", analyze);
200
+ els.emailBtn.addEventListener("click", draftEmail);
201
+ els.clearBtn.addEventListener("click", () => {
202
+ els.contractText.value = "";
203
+ els.sourceBanner.innerHTML = "";
204
+ els.fileName.textContent = "or edit the docket text below";
205
+ lastFindings = [];
206
+ renderEmpty("Paste or upload a contract, then run the scan.");
207
+ });
208
+ els.fileInput.addEventListener("change", async (event) => {
209
+ const file = event.target.files?.[0];
210
+ if (!file) return;
211
+ els.contractText.value = await file.text();
212
+ els.sourceBanner.innerHTML = "";
213
+ els.fileName.textContent = file.name;
214
+ lastFindings = [];
215
+ renderEmpty("Uploaded text is ready for analysis.");
216
+ });
217
+ }
218
+
219
+ async function init() {
220
+ populateProof();
221
+ populateExamples();
222
+ bindEvents();
223
+ await connectClient();
224
+ await loadExample(bootstrap.default_example);
225
+ }
226
+
227
+ init();
static/lease-lens-mark.svg ADDED