Commit ·
aa802c4
1
Parent(s): 2e29621
feat: add custom legal evidence desk UI
Browse filesCo-authored-by: OpenAI Codex <codex@openai.com>
- .gitignore +2 -0
- README.md +3 -0
- app.py +426 -198
- docs/codex-build-log.md +6 -10
- index.html +118 -0
- static/app.css +616 -0
- static/app.js +227 -0
- 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
|
| 2 |
-
# Fine-tuned 3B legal model (adapter: giladam01/lease-lens-legal-3b) on
|
| 3 |
-
#
|
| 4 |
import json
|
| 5 |
import html as _html
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
import gradio as gr
|
| 9 |
-
|
| 10 |
-
|
| 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 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
torch.cuda.device_count = lambda: 0
|
| 41 |
try:
|
| 42 |
-
model = PeftModel.from_pretrained(_base, ADAPTER)
|
| 43 |
-
|
| 44 |
-
torch.cuda.is_available, torch.cuda.device_count
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 94 |
-
return
|
| 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
|
| 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 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
text = (text or "").strip()
|
| 139 |
if len(text) < 40:
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = []
|
| 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]:
|
| 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 |
-
|
| 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 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
findings, snippets, used, found_cats = [], [], [], set()
|
| 175 |
for (c, _ch), a in zip(pairs, answers):
|
| 176 |
-
if c["label"] in found_cats:
|
| 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:
|
| 184 |
continue
|
| 185 |
-
if any(ncs[:80] == u[:80] or ncs in u or u in ncs for u in used):
|
| 186 |
continue
|
| 187 |
-
if not any(k in ncs for k in c["kw"]):
|
| 188 |
continue
|
| 189 |
-
used.append(ncs)
|
| 190 |
-
|
|
|
|
|
|
|
| 191 |
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
yield render_results(findings, skipped, len(covered)) + extra, highlight(text, snippets), state
|
| 197 |
|
| 198 |
|
| 199 |
-
def
|
| 200 |
try:
|
| 201 |
-
findings = json.loads(
|
| 202 |
except Exception:
|
| 203 |
findings = []
|
| 204 |
if not findings:
|
| 205 |
-
return "Run an analysis first
|
| 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] + "
|
| 218 |
-
return out + "\n\n---\nDraft for review
|
| 219 |
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
-
|
| 234 |
-
|
| 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 |
-
|
| 240 |
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 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 |
-
|
| 272 |
-
|
| 273 |
-
|
| 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 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
return ""
|
| 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 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("&", "&")
|
| 32 |
+
.replaceAll("<", "<")
|
| 33 |
+
.replaceAll(">", ">")
|
| 34 |
+
.replaceAll('"', """)
|
| 35 |
+
.replaceAll("'", "'");
|
| 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
|
|