mikeljl commited on
Commit
2d03c5f
Β·
1 Parent(s): e63382b

update to lean_finder_v1_updated endpoint, UI change

Browse files
Files changed (4) hide show
  1. .gitignore +6 -0
  2. README.md +5 -0
  3. app.py +1176 -950
  4. requirements.txt +2 -1
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ venv/
6
+ .DS_Store
README.md CHANGED
@@ -14,3 +14,8 @@ short_description: Code search for Lean 4
14
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
15
 
16
  arxiv.org/abs/2510.15940
 
 
 
 
 
 
14
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
15
 
16
  arxiv.org/abs/2510.15940
17
+
18
+ ## Acknowledgements
19
+
20
+ Lean Finder is built on open-source projects like [Jixia](https://github.com/frenzymath/jixia) and [LeanDojo](https://github.com/lean-dojo/leandojo). We were inspired by [LeanSearch](https://leansearch.net/), and we use LeanSearch in our Arena mode to let users compare results. Thanks to the creators of these awesome tools!
21
+
app.py CHANGED
@@ -1,1186 +1,1412 @@
1
- import os, json, datetime, threading, requests, random, re, html
2
  from typing import List, Dict, Any
 
3
  import gradio as gr
4
  import gspread
5
  from google.oauth2.service_account import Credentials
6
  from gspread.exceptions import WorksheetNotFound
7
- import time
 
 
 
 
8
 
9
  ENDPOINT_ID_A = os.getenv("ENDPOINT_ID_A")
10
  ENDPOINT_ID_B = os.getenv("ENDPOINT_ID_B")
11
- HF_TOKEN = os.getenv("HF_TOKEN")
12
  SERVICE_ACCOUNT_INFO = os.getenv("GCP_SERVICE_ACCOUNT_JSON")
 
13
  SCOPES = [
14
  "https://www.googleapis.com/auth/spreadsheets",
15
  "https://www.googleapis.com/auth/drive",
16
  ]
17
 
18
- credentials = Credentials.from_service_account_info(
19
- json.loads(SERVICE_ACCOUNT_INFO), scopes=SCOPES
20
- )
21
  if not ENDPOINT_ID_A:
22
- raise ValueError("ENDPOINT_ID is not set")
23
  if not ENDPOINT_ID_B:
24
  raise ValueError("ENDPOINT_ID_B is not set")
 
 
 
 
 
 
25
 
26
- def _call_retriever_system1(payload: Dict[str, Any]) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
27
  headers = {"Accept": "application/json", "Content-Type": "application/json"}
28
  if HF_TOKEN:
29
  headers["Authorization"] = f"Bearer {HF_TOKEN}"
 
 
 
 
 
 
30
  try:
31
  r = requests.post(ENDPOINT_ID_A, json=payload, headers=headers, timeout=60)
32
  r.raise_for_status()
33
- return r.json()
34
- except requests.exceptions.RequestException as e:
35
- raise RuntimeError("Error: failed to contact retriever system 1. Please try again.")
36
 
37
- def _call_retriever_system2(query: str, k: int) -> List[Dict[str, Any]]:
 
38
  payload = {"query": [query], "num_results": str(k)}
39
  try:
40
  r = requests.post(ENDPOINT_ID_B, json=payload, timeout=60)
41
  r.raise_for_status()
42
  data = r.json()
43
  return data[0] if isinstance(data, list) and data else []
44
- except requests.exceptions.RequestException as e:
45
- raise RuntimeError("Error: failed to contact retriever system 2. Please try again.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
- gc = gspread.authorize(credentials)
48
- worksheet = gc.open("arena_votes").sheet1
49
- vote_worksheet = gc.open("arena_votes").worksheet("individual_votes")
50
 
51
- SHEET_LOCK = threading.Lock()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
- VOTE_UI = ["Retriever A better", "Retriever B better", "Tie", "Both are bad"]
54
 
55
- def _save_vote(choice: str, query: str,
56
- ret_a_json: Dict[str, Any], ret_b_json: List[Dict[str, Any]],
57
- sys1_is_a: bool) -> gr.Textbox:
58
  if not choice:
59
- return gr.update(value="**Please pick a system.**", visible=True)
 
60
  if choice == "Retriever A better":
61
- actual_winner = "System1 better" if sys1_is_a else "System2 better"
62
  elif choice == "Retriever B better":
63
- actual_winner = "System2 better" if sys1_is_a else "System1 better"
64
  else:
65
- actual_winner = choice
66
 
67
  payload = {
68
- "retriever_a": ret_a_json,
69
- "retriever_b": ret_b_json,
70
- "sys1_is_a": sys1_is_a,
 
71
  }
72
  row = [
73
- datetime.datetime.utcnow().isoformat(timespec="seconds"),
74
  query,
75
- actual_winner,
 
 
 
 
76
  json.dumps(payload, ensure_ascii=False),
77
- "User Preference"
78
  ]
79
  with SHEET_LOCK:
80
- worksheet.append_row(row, value_input_option="RAW")
81
- return gr.update(value="**Vote recorded β€” thanks!**", visible=True)
 
82
 
83
- def _save_individual_vote(formal_statement: str, vote_decision: str, query: str, system: str, rank: int, payload: Dict[str, Any]) -> str:
 
 
84
  row = [
85
- datetime.datetime.utcnow().isoformat(timespec="seconds"),
86
  query,
87
- formal_statement,
 
88
  vote_decision,
89
  system,
90
  rank,
91
- json.dumps(payload, ensure_ascii=False)
92
  ]
93
  try:
94
  with SHEET_LOCK:
95
- vote_worksheet.append_row(row, value_input_option="RAW")
96
  return "Vote recorded!"
97
  except Exception as e:
98
- return f"Error writing to Google Sheets: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
- def _process_informal_statement(text: str) -> str:
101
- if not text:
 
 
102
  return ""
103
-
104
- text = re.sub(r'`([^`]+)`', r'<code>\1</code>', text)
105
- return text
106
-
107
- def _render_system1_results(res: List[Dict[str, Any]], title: str = "Retriever A", query: str = ""):
108
- if not res:
109
- return f"<p>No results from {title}.</p>"
110
-
111
- def make_row(i: int, r: Dict[str, Any]) -> str:
112
- formal_text = r['formal_statement']
113
- informal_text = r['informal_statement']
114
-
115
- formal_escaped = html.escape(formal_text)
116
- doc_url = r.get('url', '')
117
- doc_button = f'<button class="doc-button" onclick="window.open(\'{doc_url}\', \'_blank\')" title="View documentation">Doc</button>' if doc_url else ''
118
-
119
- formal_cell = (
120
- f'<div class="copy-container">'
121
- f'<code style="white-space:pre-wrap">{formal_escaped}</code>'
122
- f'<div class="button-container">'
123
- f'<div class="left-buttons">'
124
- f'{doc_button}'
125
- f'<button class="copy-button" data-copy-text="{formal_escaped}" onclick="copyToClipboard(this.getAttribute(\'data-copy-text\'), this)">Copy</button>'
126
- f'</div>'
127
- f'<div class="right-buttons">'
128
- f'<button class="vote-button upvote" data-formal="{formal_escaped}" data-query="{html.escape(query)}" data-system="System1" data-rank="{i}" onclick="voteOnResultSafe(this, \'Upvote\')">πŸ‘</button>'
129
- f'<button class="vote-button downvote" data-formal="{formal_escaped}" data-query="{html.escape(query)}" data-system="System1" data-rank="{i}" onclick="voteOnResultSafe(this, \'Downvote\')">πŸ‘Ž</button>'
130
- f'</div>'
131
- f'</div>'
132
- f'</div>'
133
- )
134
-
135
- informal_cell = (
136
- f'<div class="copy-container">'
137
- f'<span style="white-space:pre-wrap">{_process_informal_statement(informal_text)}</span>'
138
- f'<div class="button-container">'
139
- f'<button class="copy-button" data-copy-text="{informal_text}" onclick="copyToClipboard(this.getAttribute(\'data-copy-text\'), this)">Copy</button>'
140
- f'</div>'
141
- f'</div>'
142
- )
143
-
144
- return f"<tr><td>{i}</td><td>{formal_cell}</td><td>{informal_cell}</td></tr>"
145
-
146
- rows = "\n".join(make_row(i, r) for i, r in enumerate(res, 1))
147
  return (
148
- f"<h3>{title}</h3>"
149
- "<table><thead><tr><th>Rank</th>"
150
- "<th>Formal statement</th><th>Informal statement</th></tr></thead>"
151
- f"<tbody>{rows}</tbody></table>"
152
  )
153
 
154
- def _render_system2_results(res: List[Dict[str, Any]], title: str = "Retriever B", query: str = ""):
155
- if not res:
156
- return f"<p>No results from {title}.</p>"
157
-
158
- def row(i: int, e: Dict[str, Any]) -> str:
159
- r = e.get("result", {})
160
- kind, name = r.get("kind","").strip(), ".".join(r.get("name", []))
161
- sig = r.get("signature") or ""
162
- val = (r.get("value") or "").lstrip()
163
- formal_text = f"{kind} {name}{sig} {val}".strip()
164
- informal_text = r.get('informal_description','')
165
-
166
- formal_escaped = html.escape(formal_text)
167
- full_name = ".".join(r.get("name", []))
168
- doc_url = f"https://leanprover-community.github.io/mathlib4_docs/find/?pattern={full_name}#doc" if full_name else ""
169
- doc_button = f'<button class="doc-button" onclick="window.open(\'{doc_url}\', \'_blank\')" title="View documentation">Doc</button>' if doc_url else ''
170
- formal_cell = (
171
- f'<div class="copy-container">'
172
- f'<code style="white-space:pre-wrap">{formal_escaped}</code>'
173
- f'<div class="button-container">'
174
- f'<div class="left-buttons">'
175
- f'{doc_button}'
176
- f'<button class="copy-button" data-copy-text="{formal_escaped}" onclick="copyToClipboard(this.getAttribute(\'data-copy-text\'), this)">Copy</button>'
177
- f'</div>'
178
- f'<div class="right-buttons">'
179
- f'<button class="vote-button upvote" data-formal="{formal_escaped}" data-query="{html.escape(query)}" data-system="System2" data-rank="{i}" onclick="voteOnResultSafe(this, \'Upvote\')">πŸ‘</button>'
180
- f'<button class="vote-button downvote" data-formal="{formal_escaped}" data-query="{html.escape(query)}" data-system="System2" data-rank="{i}" onclick="voteOnResultSafe(this, \'Downvote\')">πŸ‘Ž</button>'
181
- f'</div>'
182
- f'</div>'
183
- f'</div>'
184
  )
185
-
186
- informal_cell = (
187
- f'<div class="copy-container">'
188
- f'<span style="white-space:pre-wrap">{_process_informal_statement(informal_text)}</span>'
189
- f'<div class="button-container">'
190
- f'<button class="copy-button" data-copy-text="{informal_text}" onclick="copyToClipboard(this.getAttribute(\'data-copy-text\'), this)">Copy</button>'
191
- f'</div>'
 
 
 
 
 
 
 
 
 
 
 
192
  f'</div>'
193
  )
194
-
195
- return f"<tr><td>{i}</td><td>{formal_cell}</td><td>{informal_cell}</td></tr>"
196
-
197
- rows = "\n".join(row(i,e) for i,e in enumerate(res,1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  return (
199
- f"<h3>{title}</h3>"
200
- "<table><thead><tr><th>Rank</th>"
201
- "<th>Formal statement</th><th>Informal statement</th></tr></thead>"
202
- f"<tbody>{rows}</tbody></table>"
203
  )
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  INSTRUCTIONS_MD = """
206
- ## Supported query types
207
-
208
- ### 1. Informalized statement
209
- Enter an informal translation of a formal Lean statement, and find relevant Lean statements.\n
210
- **Example:**
211
- ```
212
- Let L/K be a field extension and let x, y ∈ L be algebraic elements over K with the same minimal polynomial. Then the K-algebra isomorphism algEquiv between the simple field extensions K(x) and K(y) maps the generator x of K(x) to the generator y of K(y); i.e. algEquiv(x) = y.
213
- ```
214
-
215
- ### 2. User question
216
- Ask any question about Lean statements.\n
217
- **Example:**
218
- ```
219
- I'm working with algebraic elements over a field extension … Does this imply that the minimal polynomials of `x` and `y` are equal?
220
- ```
221
-
222
- ### 3. Proof State
223
- Enter the proof state of a theorem. For better results, enter a proof state followed by how you want to transform the proof state.\n
224
- **Example:**
225
- ```
226
- K : Type u_1\nE : Type u_2\ninst✝ : RCLike K\nz✝ z : K\n⊒ |re z| ≀ β€–zβ€–\nTransform the goal from proving that the absolute value of the real part of a complex number is less than or equal to its norm, to proving that the square of the real part is less than or equal to the squared norm of the number.
227
- ```
228
-
229
- ### 4. Statement definition
230
- Enter any fragment or the whole statement definition, and find statements that match the entered content. Note that the query syntax doesn't need to be perfect.\n
231
- **Example:**
232
- ```
233
- theorem restrict Ioi: restrict Ioi e = restrict Ici e
234
- ```
235
  """
236
 
237
- # Gradio app
238
  CUSTOM_CSS = """
239
- html,body{margin:0;padding:0;width:100%;}
240
- .gradio-container,.gradio-container .block{
241
- max-width:none!important;width:100%!important;padding:0 0.5rem;
 
 
 
 
 
 
 
 
 
 
 
242
  }
243
 
244
- /* Tables and code blocks */
245
- table{width:100%;border-collapse:collapse;font-size:0.9rem;}
246
- th,td{border:1px solid #ddd;padding:6px;vertical-align:top;}
247
- th{background:#f5f5f5;font-weight:600;}
248
- code{background:#f8f8f8;border:1px solid #eee;border-radius:4px;padding:2px 4px;color:#333;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
 
250
- td code{
251
- background:#f0f0f0;
252
- border:1px solid #ddd;
253
- border-radius:3px;
254
- padding:1px 3px;
255
- font-size:0.9em;
256
- font-family:Monaco, Consolas, "Courier New", monospace;
257
  }
258
 
259
- /* Dark mode support */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  @media (prefers-color-scheme: dark) {
261
- table{color:#e0e0e0;}
262
- th,td{border:1px solid #555;}
263
- th{background:#2a2a2a;color:#e0e0e0;}
264
- code{background:#2a2a2a;border:1px solid #555;color:#e0e0e0;}
265
-
266
- td code{
267
- background:#333;
268
- border:1px solid #555;
269
- color:#e0e0e0;
270
  }
271
  }
272
-
273
- /* Arena voting controls */
274
- #vote_area{
275
- margin-top:1.5rem;
276
- align-items:center;
277
- justify-content:center;
278
- gap:1rem;
279
- flex-wrap:wrap;
280
- border:none!important;
281
- box-shadow:none!important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  }
283
 
284
- #vote_radio_col{
285
- display:flex;
286
- flex-direction:column;
287
- align-items:center;
288
- gap:0.75rem;
 
 
 
 
289
  }
290
 
291
- #vote_radio .gr-radio{display:flex;gap:0.75rem;}
292
- #vote_radio label{
293
- padding:4px 14px;
294
- border:1px solid #ccc;
295
- border-radius:8px;
296
- cursor:pointer;
297
- user-select:none;
298
- transition:all .15s ease;
 
 
 
 
 
 
 
 
 
 
 
299
  }
300
- #vote_radio input[type="radio"]:checked + label{
301
- background:#0066ff;color:#fff;border-color:#0066ff;
 
302
  }
303
 
304
- #submit_btn button{
305
- padding:0.65rem 1.6rem;
306
- font-weight:600;font-size:1rem;border-radius:8px;
 
 
 
 
 
 
 
 
 
307
  }
308
 
309
- #vote_status{margin-top:0.5rem;text-align:center;} /* works for Markdown */
310
 
311
- #lf_header.gr-column{
312
- display:flex !important;
313
- flex-direction:row !important;
314
- align-items:center !important;
315
- justify-content:center !important;
316
- gap:1rem !important;
317
- width:100% !important;
318
- margin:1rem auto !important;
319
- padding:0 !important;
320
  }
 
321
 
322
- /* Alternative selector in case the above doesn't work */
323
- div#lf_header{
324
- display:flex !important;
325
- flex-direction:row !important;
326
- align-items:center !important;
327
- justify-content:center !important;
328
- gap:1rem !important;
329
- width:100% !important;
330
- margin:1rem auto !important;
331
- padding:0 !important;
 
332
  }
333
 
334
- #lf_logo{
335
- width:60px !important;
336
- height:60px !important;
337
- flex:0 0 60px !important;
338
- overflow:hidden;
339
- }
340
 
341
- #lf_logo .gr-image-toolbar{
342
- display:none !important;
 
 
 
 
 
 
 
 
 
 
343
  }
344
 
 
 
 
 
 
 
345
 
346
- /* Fix the HTML container for the title */
347
- #lf_header .gr-html{
348
- width:auto !important;
349
- min-width:auto !important;
350
- max-width:none !important;
351
- flex:0 0 auto !important;
 
 
 
 
 
 
352
  }
353
 
354
- .lf-title{
355
- margin:0 !important;
356
- padding:0 !important;
357
- font-size:1.6rem !important;
358
- font-weight:700 !important;
359
- white-space:normal !important;
360
- color:#333 !important;
361
- line-height:1.2 !important;
362
- text-align:center !important;
363
  }
364
 
365
- .gr-accordion-header {
366
- font-size: 1.05rem;
 
 
367
  font-weight: 600;
368
- cursor: pointer;
369
- padding: 0.4rem 0;
 
 
 
 
370
  }
371
 
372
- /* Progress bar overlay fix for HF Spaces */
373
- div.gradio-modal[aria-label="progress"] /* outer overlay on HF */
374
- {
375
- position: fixed !important; /* pull it out of the normal flow */
376
- top: 50% !important; /* perfectly centred in the viewport */
377
- left: 50% !important;
378
- transform: translate(-50%, -50%) !important;
379
- z-index: 2000 !important;
380
-
381
- width: clamp(260px, 70vw, 440px) !important;
382
- max-height: 140px !important;
383
- padding: 20px 24px !important;
384
-
385
- /* optional aesthetic tweaks β€” remove if you like HF’s defaults */
386
- background: var(--block-background-fill, #fff) !important;
387
- border: 1px solid #ddd !important;
388
- border-radius: 8px !important;
389
- box-shadow: 0 4px 12px rgba(0,0,0,.15) !important;
390
- pointer-events: none !important;
391
  }
392
 
393
-
394
- /* Copy button styling */
395
- .copy-container {
396
- position: relative;
397
- display: block;
398
- width: 100%;
399
- min-height: 1.5em;
400
  }
401
 
402
- .copy-button {
403
- background: rgba(0, 0, 0, 0.1);
404
- border: 1px solid #ccc;
405
- border-radius: 4px;
406
- padding: 4px 8px;
407
- font-size: 12px;
 
 
 
 
 
 
 
408
  cursor: pointer;
409
- transition: background-color 0.2s ease;
410
- color: #666;
411
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
412
- line-height: 1.2;
413
  white-space: nowrap;
414
- vertical-align: baseline;
415
- box-sizing: border-box;
 
416
  }
417
-
418
- .copy-button:hover {
419
- background: rgba(0, 0, 0, 0.2);
 
420
  }
421
 
422
- .copy-button:active {
423
- background: rgba(0, 0, 0, 0.3);
 
 
 
 
 
424
  }
425
 
426
- td {
427
- position: relative;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  }
429
-
430
- /* Query input styling */
431
- #query_input textarea {
432
- resize: vertical !important;
433
- min-height: 100px !important;
434
- max-height: 400px !important;
435
- font-size: 14px !important;
436
- line-height: 1.5 !important;
437
- padding: 12px !important;
 
 
 
438
  }
439
 
440
- #query_input .gr-textbox {
441
- min-height: 100px !important;
 
 
 
442
  }
443
 
444
- /* Alternative selectors for different Gradio versions */
445
- textarea[data-testid="textbox"] {
446
- resize: vertical !important;
447
- min-height: 100px !important;
448
- max-height: 400px !important;
449
  }
450
-
451
- div[data-testid="textbox"] textarea {
452
- resize: vertical !important;
453
- min-height: 100px !important;
454
- max-height: 400px !important;
 
 
 
 
 
 
 
 
455
  }
456
-
457
- /* Vote buttons styling */
458
- .vote-button {
 
 
 
 
 
 
 
 
 
 
 
459
  background: transparent;
460
- border: 1px solid #ddd;
461
- border-radius: 4px;
462
- padding: 4px 8px;
463
- cursor: pointer;
464
- font-size: 16px;
465
- line-height: 1.2;
466
- transition: all 0.2s ease;
467
- opacity: 0.6;
468
- vertical-align: baseline;
469
- box-sizing: border-box;
470
  }
471
 
472
- .vote-button:hover {
473
- opacity: 1;
474
- transform: scale(1.1);
 
 
475
  }
476
-
477
- .vote-button.upvote {
478
- color: #28a745;
479
- border-color: #28a745;
 
 
 
480
  }
481
 
482
- .vote-button.downvote {
483
- color: #dc3545;
484
- border-color: #dc3545;
 
 
 
 
 
485
  }
486
-
487
- /* Doc button styling */
488
- .doc-button {
489
- background: rgba(0, 123, 255, 0.1);
490
- border: 1px solid #007bff;
491
- border-radius: 4px;
492
- padding: 4px 8px;
493
- font-size: 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  cursor: pointer;
495
- transition: background-color 0.2s ease;
496
- color: #007bff;
497
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
498
- line-height: 1.2;
499
- white-space: nowrap;
500
- vertical-align: baseline;
501
- box-sizing: border-box;
502
  }
503
-
504
- .doc-button:hover {
505
- background: #007bff;
 
 
 
506
  color: white;
 
507
  }
508
 
509
- /* Button container for all buttons */
510
- .button-container {
511
- display: flex;
512
- margin-top: 8px;
513
- align-items: baseline;
514
- justify-content: space-between;
515
  }
516
-
517
- .left-buttons {
518
- display: flex;
519
- gap: 8px;
520
- align-items: baseline;
 
 
 
 
 
 
 
 
521
  }
522
-
523
- .right-buttons {
524
- display: flex;
525
- gap: 8px;
526
- align-items: baseline;
527
  }
 
 
528
 
529
- .vote-button.upvote:hover {
530
- background: #28a745;
531
- color: white;
 
 
532
  }
533
 
534
- .vote-button.downvote:hover {
535
- background: #dc3545;
536
- color: white;
 
 
 
 
 
 
 
 
 
 
537
  }
538
 
539
- .vote-button.voted {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  opacity: 1;
541
- transform: scale(1.1);
542
  }
 
543
 
544
- .vote-button.upvote.voted {
545
- background: #28a745;
546
- color: white;
547
- }
548
 
549
- .vote-button.downvote.voted {
550
- background: #dc3545;
551
- color: white;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  }
553
-
554
- /* Additional dark mode support */
555
- @media (prefers-color-scheme: dark) {
556
- .lf-title{
557
- color:#e0e0e0 !important;
558
- }
559
-
560
- #vote_radio label{
561
- border:1px solid #555;
562
- background:#2a2a2a;
563
- color:#e0e0e0;
564
- }
565
-
566
- #vote_radio input[type="radio"]:checked + label{
567
- background:#0066ff;
568
- color:#fff;
569
- border-color:#0066ff;
570
- }
571
-
572
- /* Progress bar dark mode */
573
- .gradio-container .progress-bar {
574
- background: #2a2a2a !important;
575
- border: 1px solid #555 !important;
576
- }
577
-
578
- .gradio-container .progress-bar .progress-text {
579
- color: #e0e0e0 !important;
580
- }
581
-
582
- .gradio-container .progress-bar .progress-level {
583
- background: #444 !important;
584
- }
585
-
586
- /* Copy button dark mode */
587
- .copy-button {
588
- background: rgba(255, 255, 255, 0.1);
589
- color: #ccc;
590
- border-color: #555;
591
- }
592
-
593
- .copy-button:hover {
594
- background: rgba(255, 255, 255, 0.2);
595
- color: #fff;
596
- }
597
-
598
- .copy-button:active {
599
- background: rgba(255, 255, 255, 0.3);
600
- color: #fff;
601
- }
602
-
603
- /* Query input dark mode */
604
- #query_input textarea {
605
- background: #2a2a2a !important;
606
- color: #e0e0e0 !important;
607
- }
608
-
609
- #query_input textarea:focus {
610
- border-color: #0066ff !important;
611
- box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.2) !important;
612
- }
613
-
614
- /* Vote buttons dark mode */
615
- .vote-button {
616
- border-color: #555 !important;
617
- background: #2a2a2a !important;
618
- }
619
-
620
- .vote-button.upvote {
621
- color: #4ade80 !important;
622
- border-color: #4ade80 !important;
623
- }
624
-
625
- .vote-button.downvote {
626
- color: #f87171 !important;
627
- border-color: #f87171 !important;
628
- }
629
-
630
- /* Doc button dark mode */
631
- .doc-button {
632
- background: rgba(96, 165, 250, 0.1) !important;
633
- border-color: #60a5fa !important;
634
- color: #60a5fa !important;
635
- }
636
-
637
- .doc-button:hover {
638
- background: #60a5fa !important;
639
- color: #000 !important;
640
- }
641
-
642
- .vote-button.upvote:hover {
643
- background: #4ade80 !important;
644
- color: #000 !important;
645
  }
646
-
647
- .vote-button.downvote:hover {
648
- background: #f87171 !important;
649
- color: #000 !important;
650
- }
651
-
652
- .vote-button.upvote.voted {
653
- background: #4ade80 !important;
654
- color: #000 !important;
655
- }
656
-
657
- .vote-button.downvote.voted {
658
- background: #f87171 !important;
659
- color: #000 !important;
660
- }
661
-
662
- /* Vote feedback styling */
663
- #vote_feedback {
664
- margin-top: 1rem;
665
- text-align: center;
666
- }
667
-
668
- #vote_feedback .markdown {
669
- background: #d4edda !important;
670
- border: 1px solid #c3e6cb !important;
671
- border-radius: 8px !important;
672
- padding: 12px 16px !important;
673
- color: #155724 !important;
674
- font-weight: 500 !important;
675
- margin: 0 !important;
676
- transition: opacity 0.5s ease !important;
677
- opacity: 1 !important;
678
- }
679
-
680
- /* Error styling for vote feedback */
681
- #vote_feedback .markdown:has-text("ERROR"),
682
- #vote_feedback .markdown[data-error="true"] {
683
- background: #f8d7da !important;
684
- border-color: #f5c6cb !important;
685
- color: #721c24 !important;
686
  }
687
-
688
- /* Dark mode for vote feedback */
689
- @media (prefers-color-scheme: dark) {
690
- #vote_feedback .markdown {
691
- background: #1e3a2e !important;
692
- border-color: #2d5a3d !important;
693
- color: #a7d4b4 !important;
694
- }
695
-
696
- /* Error styling in dark mode */
697
- #vote_feedback .markdown:has-text("ERROR"),
698
- #vote_feedback .markdown[data-error="true"] {
699
- background: #3d1a1a !important;
700
- border-color: #5a2d2d !important;
701
- color: #ff9999 !important;
 
 
 
 
 
 
 
 
 
 
 
 
702
  }
703
  }
704
  }
705
- """
706
 
707
- with gr.Blocks(
708
- title="Lean Finder Retrieval",
709
- css=CUSTOM_CSS,
710
- head="""
711
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css">
712
- <script src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.js"></script>
713
- <script>
714
- function renderKatex() {
715
- if (typeof katex === 'undefined') {
716
- setTimeout(renderKatex, 500);
717
- return;
718
- }
719
-
720
- const spans = document.querySelectorAll('span:not([data-katex-processed])');
721
-
722
- spans.forEach(function(span) {
723
- const text = span.textContent;
724
- if (text && text.includes('$')) {
725
- span.setAttribute('data-katex-processed', 'true');
726
-
727
- let mathExpressions = [];
728
-
729
- // Find inline math $...$
730
- let pos = 0;
731
- while (pos < text.length) {
732
- const start = text.indexOf('$', pos);
733
- if (start === -1) break;
734
- const end = text.indexOf('$', start + 1);
735
- if (end === -1) break;
736
-
737
- const isDisplayMath = (start > 0 && text[start - 1] === '$') ||
738
- (end < text.length - 1 && text[end + 1] === '$');
739
-
740
- if (!isDisplayMath) {
741
- mathExpressions.push({
742
- start: start,
743
- end: end + 1,
744
- content: text.substring(start + 1, end),
745
- type: 'inline'
746
- });
747
- pos = end + 1;
748
- } else {
749
- pos = start + 1;
750
- }
751
- }
752
-
753
- // Find display math $$...$$
754
- pos = 0;
755
- while (pos < text.length) {
756
- const start = text.indexOf('$$', pos);
757
- if (start === -1) break;
758
- const end = text.indexOf('$$', start + 2);
759
- if (end === -1) break;
760
-
761
- mathExpressions.push({
762
- start: start,
763
- end: end + 2,
764
- content: text.substring(start + 2, end),
765
- type: 'display'
766
- });
767
- pos = end + 2;
768
- }
769
-
770
- mathExpressions.sort((a, b) => a.start - b.start);
771
-
772
- // Remove overlapping expressions
773
- let filtered = [];
774
- for (let expr of mathExpressions) {
775
- let shouldAdd = true;
776
- for (let j = 0; j < filtered.length; j++) {
777
- const existing = filtered[j];
778
- if (!(expr.end <= existing.start || expr.start >= existing.end)) {
779
- if (expr.type === 'display' && existing.type === 'inline') {
780
- filtered.splice(j, 1);
781
- j--;
782
- } else {
783
- shouldAdd = false;
784
- break;
785
- }
786
- }
787
- }
788
- if (shouldAdd) filtered.push(expr);
789
- }
790
-
791
- if (filtered.length > 0) {
792
- let newContent = document.createDocumentFragment();
793
- let lastPos = 0;
794
-
795
- filtered.forEach(function(expr) {
796
- if (expr.start > lastPos) {
797
- newContent.appendChild(document.createTextNode(text.substring(lastPos, expr.start)));
798
- }
799
-
800
- try {
801
- const mathElement = document.createElement('span');
802
- mathElement.innerHTML = katex.renderToString(expr.content, {
803
- throwOnError: false,
804
- displayMode: expr.type === 'display'
805
- });
806
- newContent.appendChild(mathElement);
807
- } catch (e) {
808
- const fallback = expr.type === 'display' ? '$$' + expr.content + '$$' : '$' + expr.content + '$';
809
- newContent.appendChild(document.createTextNode(fallback));
810
- }
811
-
812
- lastPos = expr.end;
813
- });
814
-
815
- if (lastPos < text.length) {
816
- newContent.appendChild(document.createTextNode(text.substring(lastPos)));
817
- }
818
-
819
- span.innerHTML = '';
820
- span.appendChild(newContent);
821
  }
822
  }
823
- });
824
- }
825
-
826
- setTimeout(renderKatex, 1000);
827
- setTimeout(renderKatex, 3000);
828
- setTimeout(renderKatex, 5000);
829
-
830
- function copyToClipboard(text, button) {
831
- if (navigator.clipboard && window.isSecureContext) {
832
- navigator.clipboard.writeText(text).then(function() {
833
- showCopySuccess(button);
834
- }).catch(function() {
835
- fallbackCopy(text, button);
836
- });
837
- } else {
838
- fallbackCopy(text, button);
839
  }
840
- }
841
-
842
- function fallbackCopy(text, button) {
843
- const textArea = document.createElement('textarea');
844
- textArea.value = text;
845
- textArea.style.position = 'fixed';
846
- textArea.style.left = '-9999px';
847
- textArea.style.top = '-9999px';
848
- document.body.appendChild(textArea);
849
- textArea.focus();
850
- textArea.select();
851
-
852
- try {
853
- document.execCommand('copy');
854
- showCopySuccess(button);
855
- } catch (err) {
856
- showCopyError(button);
857
  }
858
-
859
- document.body.removeChild(textArea);
860
- }
861
-
862
- function showCopySuccess(button) {
863
- const originalText = button.innerHTML;
864
- button.innerHTML = 'βœ“';
865
- button.style.color = '#28a745';
866
- setTimeout(function() {
867
- button.innerHTML = originalText;
868
- button.style.color = '';
869
- }, 1000);
870
- }
871
-
872
- function showCopyError(button) {
873
- const originalText = button.innerHTML;
874
- button.innerHTML = 'βœ—';
875
- button.style.color = '#dc3545';
876
- setTimeout(function() {
877
- button.innerHTML = originalText;
878
- button.style.color = '';
879
- }, 1000);
880
  }
881
-
882
- function voteOnResultSafe(button, voteDecision) {
883
- const formalStatement = button.getAttribute('data-formal');
884
- const query = button.getAttribute('data-query');
885
- const system = button.getAttribute('data-system');
886
- const rank = parseInt(button.getAttribute('data-rank'));
887
-
888
- button.classList.add('voted');
889
-
890
- const otherButton = button.parentElement.querySelector('.vote-button:not(.' + (voteDecision === 'Upvote' ? 'upvote' : 'downvote') + ')');
891
- if (otherButton) {
892
- otherButton.style.opacity = '0.3';
893
- otherButton.style.pointerEvents = 'none';
894
- }
895
-
896
- const originalText = button.innerHTML;
897
- button.innerHTML = voteDecision === 'Upvote' ? 'πŸ‘βœ“' : 'πŸ‘Žβœ“';
898
- setTimeout(function() {
899
- button.innerHTML = originalText;
900
- }, 2000);
901
-
902
- const voteData = JSON.stringify({
903
- formal_statement: formalStatement,
904
- vote_decision: voteDecision,
905
- query: query,
906
- system: system,
907
- rank: rank
908
- });
909
-
910
- const voteContainer = document.querySelector('#vote_trigger_input');
911
- if (voteContainer) {
912
- const input = voteContainer.querySelector('textarea') || voteContainer.querySelector('input[type="text"]');
913
- if (input) {
914
- input.value = voteData;
915
- input.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
916
- input.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
917
-
918
- setTimeout(function() {
919
- const feedbackElement = document.querySelector('#vote_feedback .markdown');
920
- if (feedbackElement) {
921
- if (feedbackElement.textContent.includes('ERROR')) {
922
- feedbackElement.setAttribute('data-error', 'true');
923
- } else {
924
- feedbackElement.removeAttribute('data-error');
925
- }
926
- }
927
- }, 100);
928
-
929
- setTimeout(function() {
930
- const feedbackElement = document.querySelector('#vote_feedback .markdown');
931
- if (feedbackElement && feedbackElement.textContent.trim()) {
932
- feedbackElement.style.opacity = '0';
933
- setTimeout(function() {
934
- const input = voteContainer.querySelector('textarea') || voteContainer.querySelector('input[type="text"]');
935
- if (input) {
936
- input.value = '';
937
- input.dispatchEvent(new Event('input', { bubbles: true }));
938
- }
939
- }, 500);
940
- }
941
- }, 4000);
942
  }
943
- }
944
  }
945
-
946
- function voteOnResult(formalStatement, voteDecision, query, system, rank, button) {
947
- const tempButton = document.createElement('button');
948
- tempButton.setAttribute('data-formal', formalStatement);
949
- tempButton.setAttribute('data-query', query);
950
- tempButton.setAttribute('data-system', system);
951
- tempButton.setAttribute('data-rank', rank);
952
- tempButton.className = button.className;
953
- tempButton.parentElement = button.parentElement;
954
- voteOnResultSafe(tempButton, voteDecision);
955
  }
956
-
957
- setInterval(function() {
958
- if (document.querySelector('table')) {
959
- renderKatex();
960
- }
961
- }, 3000);
962
- </script>
963
- """
964
- ) as demo:
965
- with gr.Column(elem_id="lf_header"):
966
- gr.Image(
967
- value="lean_finder_logo.png",
968
- show_label=False,
969
- interactive=False,
970
- container=False,
971
- show_download_button=False,
972
- height=60,
973
- elem_id="lf_logo",
974
- )
975
- gr.HTML('<h1 class="lf-title">Lean Finder: Semantic Search for Mathlib That Understands User Intents</h1>')
976
 
977
- with gr.Accordion("Supported query types(click for details): Informalized statement, User question, Proof state, Statement definition", open=False):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
978
  gr.Markdown(INSTRUCTIONS_MD)
979
 
980
- gr.Markdown("Currently using Mathlib v4.16.0")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
981
 
 
982
 
 
 
 
 
 
 
 
983
 
984
- with gr.Row():
985
- query_box = gr.Textbox(label="Query", lines=4, max_lines=20,
986
- placeholder="Type your query here …",
987
- elem_id="query_input")
988
- topk_slider = gr.Slider(label="Number of results", minimum=1, maximum=50, step=1, value=5)
989
  with gr.Column():
990
- mode_sel = gr.Radio(["Arena", "Normal"], value="Arena", label="Mode")
991
- mode_description = gr.Markdown("Arena mode: Compare retrieval results from Lean Finder with another retriever and vote.\n By voting, you agree that your responses may be used to help improve Lean Finder. We only collect the voting results; no identifiable information is stored.")
992
-
993
- run_btn = gr.Button("Retrieve")
994
-
995
- with gr.Row(elem_id="vote_area"):
996
- with gr.Column(elem_id="vote_radio_col"):
997
  vote_radio = gr.Radio(
998
  VOTE_UI,
999
- label="Which result is better?",
1000
- visible=False,
1001
- elem_id="vote_radio"
1002
  )
1003
  submit_btn = gr.Button(
1004
  "Submit vote",
1005
- visible=False,
1006
  elem_id="submit_btn",
1007
- variant="primary"
1008
  )
1009
  vote_status = gr.Markdown("", visible=False, elem_id="vote_status")
1010
 
1011
- results_html = gr.HTML()
1012
-
1013
- gr.HTML('''
1014
- <div style="text-align: center; margin: 0.75rem 0;">
1015
- <a href="https://arxiv.org/abs/2510.15940" target="_blank" style="
1016
- display: inline-flex;
1017
- align-items: center;
1018
- gap: 0.4rem;
1019
- padding: 0.4rem 0.8rem;
1020
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1021
- color: white;
1022
- text-decoration: none;
1023
- border-radius: 20px;
1024
- font-weight: 500;
1025
- font-size: 0.85rem;
1026
- box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
1027
- transition: all 0.3s ease;
1028
- border: none;
1029
- " onmouseover="this.style.transform='translateY(-1px)'; this.style.boxShadow='0 4px 12px rgba(102, 126, 234, 0.35)'"
1030
- onmouseout="this.style.transform='translateY(0px)'; this.style.boxShadow='0 2px 8px rgba(102, 126, 234, 0.25)'">
1031
- <span style="font-size: 1rem;">πŸ“„</span>
1032
- Read our paper for details about Lean Finder
1033
- </a>
1034
- </div>
1035
- ''', elem_id="paper_link")
1036
-
1037
- # Hidden component for individual vote triggers
1038
- vote_trigger = gr.Textbox(visible=False, elem_id="vote_trigger_input")
1039
  vote_feedback = gr.Markdown("", visible=True, elem_id="vote_feedback")
1040
 
1041
- # per-session state
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1042
  st_query = gr.State("")
1043
- st_ret_a_js = gr.State({})
1044
- st_ret_b_js = gr.State([])
1045
- st_sys1_is_a = gr.State(True) # Track which system is A in current session
 
 
 
 
 
 
 
 
1046
 
1047
- def retrieve(query: str, k: int, mode: str, progress=gr.Progress()):
1048
- query = query.strip()
1049
  if not query:
1050
- hide = gr.update(visible=False, value="")
1051
- return "<p>Please enter a query.</p>", query, {}, [], hide, hide, hide, True
 
 
 
1052
 
 
1053
  try:
1054
- sys1_json = _call_retriever_system1({"inputs": query, "top_k": k}).get("results", [])
1055
  except RuntimeError:
1056
- payload = {"inputs": query, "top_k": k}
1057
- hide = gr.update(visible=False, value="")
1058
- progress(0, desc="Please wait about 2 minutes. The Lean Finder service is starting up. Results will be displayed automatically once Lean Finder is ready.")
1059
-
1060
- start_time = time.time()
1061
- timeout_duration = 300
1062
- progress_phase1_duration = 120
1063
- last_progress_update = 0
1064
-
1065
- while time.time() - start_time < timeout_duration:
1066
- elapsed_time = time.time() - start_time
1067
- if elapsed_time - last_progress_update >= 10:
1068
- if elapsed_time < progress_phase1_duration:
1069
- progress(elapsed_time / progress_phase1_duration, desc="Please wait about 2 minutes. The Lean Finder service is starting up. Results will be displayed automatically once Lean Finder is ready.")
1070
- else:
1071
- progress(1.0, desc="Experiencing a slow start. Please wait a little longer β€” the content will be displayed once Lean Finder is ready.")
1072
- last_progress_update = elapsed_time
1073
-
1074
- time.sleep(1)
1075
  try:
1076
- sys1_json = _call_retriever_system1(payload).get("results", [])
1077
  break
1078
  except RuntimeError:
1079
- continue
1080
- else:
1081
- error_message = "<div style='background-color: #f8d7da; border: 1px solid #f5c6cb; padding: 10px; margin-bottom: 15px; border-radius: 5px; color: #721c24;'><strong>Error:</strong> Lean Finder service is currently unavailable. Please contact the maintainer of this project at mike_lu@sfu.ca</div>"
1082
- return error_message, query, {}, [], hide, hide, hide, True
 
 
 
 
 
 
 
1083
 
1084
  if mode == "Normal":
1085
- sys1_html = _render_system1_results(sys1_json, title="Lean Finder", query=query)
1086
- hide = gr.update(visible=False, value="")
1087
- return sys1_html, query, sys1_json, [], hide, hide, hide, True
 
 
1088
 
 
1089
  try:
1090
- sys2_json = _call_retriever_system2(query, k)
 
1091
  except RuntimeError:
1092
- error_message = "<div style='background-color: #fff3cd; border: 1px solid #ffeaa7; padding: 10px; margin-bottom: 15px; border-radius: 5px; color: #856404;'><strong>Notice:</strong> The other retriever Lean Search is currently unavailable, falling back to Normal mode.</div>"
1093
- sys1_html = _render_system1_results(sys1_json, title="Lean Finder", query=query)
1094
- fallback_html = error_message + sys1_html
1095
- hide = gr.update(visible=False, value="")
1096
- return fallback_html, query, sys1_json, [], hide, hide, hide, True
1097
-
1098
- sys1_is_a = random.choice([True, False])
1099
- if sys1_is_a:
1100
- ret_a_json, ret_b_json = sys1_json, sys2_json
1101
- ret_a_html = _render_system1_results(ret_a_json, title="Retriever A", query=query)
1102
- ret_b_html = _render_system2_results(ret_b_json, title="Retriever B", query=query)
 
 
 
 
 
1103
  else:
1104
- ret_a_json, ret_b_json = sys2_json, sys1_json
1105
- ret_a_html = _render_system2_results(ret_a_json, title="Retriever A", query=query)
1106
- ret_b_html = _render_system1_results(ret_b_json, title="Retriever B", query=query)
1107
-
1108
- page = (
1109
- "<div style='display:flex; gap:0.5rem;'>"
1110
- f"<div style='flex:1 1 0;'>{ret_a_html}</div>"
1111
- f"<div style='flex:1 1 0;'>{ret_b_html}</div>"
1112
- "</div>"
1113
  )
1114
- show_radio = gr.update(visible=True, value=None)
1115
- hide_status = gr.update(visible=False, value="")
1116
- show_btn = gr.update(visible=True)
1117
- return page, query, ret_a_json, ret_b_json, show_radio, show_btn, hide_status, sys1_is_a
1118
 
1119
  run_btn.click(
1120
  retrieve,
1121
- inputs=[query_box, topk_slider, mode_sel],
1122
- outputs=[results_html, st_query, st_ret_a_js, st_ret_b_js,
1123
- vote_radio, submit_btn, vote_status, st_sys1_is_a],
 
1124
  )
1125
 
1126
- def _reset_ui_on_mode_change(mode):
1127
  if mode == "Arena":
1128
- description = "Arena mode: Compare retrieval results from Lean Finder with another retriever and vote."
 
 
 
 
1129
  else:
1130
- description = "Normal mode: Standard retrieval from Lean Finder."
1131
-
 
1132
  return (
1133
- "",
 
1134
  gr.update(visible=False, value=None),
1135
  gr.update(visible=False),
1136
  gr.update(visible=False, value=""),
1137
- description,
1138
  )
1139
 
1140
  mode_sel.change(
1141
- _reset_ui_on_mode_change,
1142
  inputs=mode_sel,
1143
- outputs=[results_html, vote_radio, submit_btn, vote_status, mode_description],
1144
  )
1145
 
1146
  submit_btn.click(
1147
  _save_vote,
1148
- inputs=[vote_radio, st_query, st_ret_a_js, st_ret_b_js, st_sys1_is_a],
1149
- outputs=vote_status
 
1150
  )
1151
-
1152
- def handle_individual_vote(vote_data: str, query: str, ret_a_js: Dict[str, Any], ret_b_js: List[Dict[str, Any]], sys1_is_a: bool) -> str:
 
 
1153
  if not vote_data:
1154
  return ""
1155
  try:
1156
  data = json.loads(vote_data)
1157
-
 
 
1158
  payload = {
1159
- "retriever_a": ret_a_js,
1160
- "retriever_b": ret_b_js,
1161
- "sys1_is_a": sys1_is_a,
1162
- "individual_vote": data
 
1163
  }
1164
-
1165
  _save_individual_vote(
1166
  data["formal_statement"],
1167
  data["vote_decision"],
1168
  data["query"],
1169
- data["system"],
1170
  data["rank"],
1171
- payload
 
1172
  )
1173
- return f"**{data['vote_decision']} recorded!** (Rank {data['rank']})"
1174
  except (json.JSONDecodeError, KeyError) as e:
1175
- return f"Error saving vote: {str(e)}"
1176
  except Exception as e:
1177
- return f"ERROR: {str(e)}"
1178
-
1179
  vote_trigger.change(
1180
  handle_individual_vote,
1181
- inputs=[vote_trigger, st_query, st_ret_a_js, st_ret_b_js, st_sys1_is_a],
1182
- outputs=vote_feedback
 
1183
  )
1184
 
 
1185
  if __name__ == "__main__":
1186
- demo.launch()
 
1
+ import os, json, datetime, threading, requests, random, html, time
2
  from typing import List, Dict, Any
3
+
4
  import gradio as gr
5
  import gspread
6
  from google.oauth2.service_account import Credentials
7
  from gspread.exceptions import WorksheetNotFound
8
+ try:
9
+ from dotenv import load_dotenv
10
+ load_dotenv()
11
+ except ImportError:
12
+ pass # In deployment the env vars are already set
13
 
14
  ENDPOINT_ID_A = os.getenv("ENDPOINT_ID_A")
15
  ENDPOINT_ID_B = os.getenv("ENDPOINT_ID_B")
16
+ HF_TOKEN = os.getenv("HF_TOKEN", "")
17
  SERVICE_ACCOUNT_INFO = os.getenv("GCP_SERVICE_ACCOUNT_JSON")
18
+
19
  SCOPES = [
20
  "https://www.googleapis.com/auth/spreadsheets",
21
  "https://www.googleapis.com/auth/drive",
22
  ]
23
 
 
 
 
24
  if not ENDPOINT_ID_A:
25
+ raise ValueError("ENDPOINT_ID_A is not set")
26
  if not ENDPOINT_ID_B:
27
  raise ValueError("ENDPOINT_ID_B is not set")
28
+ if not SERVICE_ACCOUNT_INFO:
29
+ raise ValueError("GCP_SERVICE_ACCOUNT_JSON is not set")
30
+
31
+ credentials = Credentials.from_service_account_info(
32
+ json.loads(SERVICE_ACCOUNT_INFO), scopes=SCOPES
33
+ )
34
 
35
+ # Mathlib versions supported by the new endpoint
36
+ MATHLIB_VERSIONS = ["v4.19.0", "v4.24.0", "v4.28.0"]
37
+ DEFAULT_VERSION = "v4.19.0"
38
+
39
+ LEAN_FINDER = "Lean Finder"
40
+ LEAN_SEARCH = "LeanSearch"
41
+ APP_SOURCE = "lean finder web"
42
+
43
+
44
+ # Retriever calls
45
+
46
+ def _call_lean_finder(query: str, k: int, version: str) -> List[Dict[str, Any]]:
47
  headers = {"Accept": "application/json", "Content-Type": "application/json"}
48
  if HF_TOKEN:
49
  headers["Authorization"] = f"Bearer {HF_TOKEN}"
50
+ payload = {
51
+ "inputs": query,
52
+ "top_k": k,
53
+ "version": version,
54
+ "source": APP_SOURCE,
55
+ }
56
  try:
57
  r = requests.post(ENDPOINT_ID_A, json=payload, headers=headers, timeout=60)
58
  r.raise_for_status()
59
+ return r.json().get("results", []) or []
60
+ except requests.exceptions.RequestException:
61
+ raise RuntimeError("Lean Finder service is unreachable.")
62
 
63
+
64
+ def _call_lean_search(query: str, k: int) -> List[Dict[str, Any]]:
65
  payload = {"query": [query], "num_results": str(k)}
66
  try:
67
  r = requests.post(ENDPOINT_ID_B, json=payload, timeout=60)
68
  r.raise_for_status()
69
  data = r.json()
70
  return data[0] if isinstance(data, list) and data else []
71
+ except requests.exceptions.RequestException:
72
+ raise RuntimeError("LeanSearch service is unreachable.")
73
+
74
+
75
+ # Normalize results from both retrievers into a common schema
76
+
77
+ def _norm_lean_finder(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
78
+ out = []
79
+ for r in results:
80
+ out.append({
81
+ "formal_name": (r.get("formal_name") or "").strip(),
82
+ "informal_name": (r.get("informal_name") or "").strip(),
83
+ "kind": (r.get("kind") or "").strip(),
84
+ "type": (r.get("type") or "").strip(),
85
+ "informal_description": (r.get("informal_description") or "").strip(),
86
+ "path": (r.get("path") or "").strip(),
87
+ "score": float(r.get("score") or 0.0),
88
+ "source": LEAN_FINDER,
89
+ })
90
+ return out
91
+
92
+
93
+ def _norm_lean_search(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
94
+ out = []
95
+ for e in results:
96
+ r = e.get("result", {}) or {}
97
+ formal_name = ".".join(r.get("name") or [])
98
+ module = r.get("module_name") or []
99
+ path = "/".join(module) if module else ""
100
+ type_str = (r.get("type") or r.get("signature") or "").strip()
101
+ # LeanSearch returns a distance (lower is better, ~cosine). Convert to a
102
+ # similarity-ish score for display only.
103
+ try:
104
+ distance = float(e.get("distance") or 1.0)
105
+ except (TypeError, ValueError):
106
+ distance = 1.0
107
+ score = max(0.0, min(1.0, 1.0 - distance))
108
+ out.append({
109
+ "formal_name": formal_name.strip(),
110
+ "informal_name": (r.get("informal_name") or "").strip(),
111
+ "kind": (r.get("kind") or "").strip(),
112
+ "type": type_str,
113
+ "informal_description": (r.get("informal_description") or "").strip(),
114
+ "path": path,
115
+ "score": score,
116
+ "source": LEAN_SEARCH,
117
+ })
118
+ return out
119
+
120
+
121
+ # Google Sheets β€” new sheet names
122
+
123
+ gc = gspread.authorize(credentials)
124
+ SHEET_LOCK = threading.Lock()
125
+ _spreadsheet = gc.open("arena_votes")
126
 
 
 
 
127
 
128
+ def _get_or_create_sheet(name: str, header: List[str]):
129
+ try:
130
+ ws = _spreadsheet.worksheet(name)
131
+ # Ensure header exists
132
+ try:
133
+ first = ws.row_values(1)
134
+ except Exception:
135
+ first = []
136
+ if not first:
137
+ ws.append_row(header, value_input_option="RAW")
138
+ return ws
139
+ except WorksheetNotFound:
140
+ ws = _spreadsheet.add_worksheet(title=name, rows=2000, cols=max(12, len(header)))
141
+ ws.append_row(header, value_input_option="RAW")
142
+ return ws
143
+
144
+
145
+ comparison_ws = _get_or_create_sheet(
146
+ "leansearch_comparison_updated",
147
+ ["timestamp", "query", "version", "ui_choice", "winner", "a_source", "b_source", "payload", "type"],
148
+ )
149
+ individual_ws = _get_or_create_sheet(
150
+ "individual_votes_updated",
151
+ ["timestamp", "query", "version", "formal_name", "vote_decision", "system", "rank", "payload"],
152
+ )
153
+
154
+
155
+ VOTE_UI = ["Retriever A better", "Retriever B better", "Tie", "Both are bad"]
156
 
 
157
 
158
+ def _save_vote(choice: str, query: str, version: str,
159
+ ret_a: List[Dict[str, Any]], ret_b: List[Dict[str, Any]],
160
+ a_source: str, b_source: str):
161
  if not choice:
162
+ return gr.update(value="⚠️ Please pick a winner first.", visible=True)
163
+
164
  if choice == "Retriever A better":
165
+ winner = a_source
166
  elif choice == "Retriever B better":
167
+ winner = b_source
168
  else:
169
+ winner = choice # "Tie" or "Both are bad"
170
 
171
  payload = {
172
+ "retriever_a": ret_a,
173
+ "retriever_b": ret_b,
174
+ "a_source": a_source,
175
+ "b_source": b_source,
176
  }
177
  row = [
178
+ datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None).isoformat(timespec="seconds"),
179
  query,
180
+ version,
181
+ choice,
182
+ winner,
183
+ a_source,
184
+ b_source,
185
  json.dumps(payload, ensure_ascii=False),
186
+ "User Preference",
187
  ]
188
  with SHEET_LOCK:
189
+ comparison_ws.append_row(row, value_input_option="RAW")
190
+ return gr.update(value="βœ… **Vote recorded β€” thanks!**", visible=True)
191
+
192
 
193
+ def _save_individual_vote(formal_name: str, vote_decision: str, query: str,
194
+ system: str, rank: int, version: str,
195
+ payload: Dict[str, Any]) -> str:
196
  row = [
197
+ datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None).isoformat(timespec="seconds"),
198
  query,
199
+ version,
200
+ formal_name,
201
  vote_decision,
202
  system,
203
  rank,
204
+ json.dumps(payload, ensure_ascii=False),
205
  ]
206
  try:
207
  with SHEET_LOCK:
208
+ individual_ws.append_row(row, value_input_option="RAW")
209
  return "Vote recorded!"
210
  except Exception as e:
211
+ return f"Error writing to Google Sheets: {e}"
212
+
213
+
214
+ # Rendering
215
+
216
+ KIND_COLORS = {
217
+ "theorem": ("#dbeafe", "#1d4ed8"),
218
+ "lemma": ("#dbeafe", "#1d4ed8"),
219
+ "def": ("#d1fae5", "#047857"),
220
+ "definition": ("#d1fae5", "#047857"),
221
+ "structure": ("#ede9fe", "#6d28d9"),
222
+ "class": ("#ede9fe", "#6d28d9"),
223
+ "inductive": ("#ffedd5", "#c2410c"),
224
+ "instance": ("#fce7f3", "#be185d"),
225
+ "abbrev": ("#cffafe", "#0e7490"),
226
+ "opaque": ("#e5e7eb", "#374151"),
227
+ "axiom": ("#fef3c7", "#a16207"),
228
+ }
229
 
230
+
231
+ def _kind_chip(kind: str) -> str:
232
+ label = (kind or "").strip()
233
+ if not label:
234
  return ""
235
+ bg, fg = KIND_COLORS.get(label.lower(), ("#e5e7eb", "#374151"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  return (
237
+ f'<span class="kind-chip" '
238
+ f'style="background:{bg};color:{fg};">{html.escape(label)}</span>'
 
 
239
  )
240
 
241
+
242
+ def _result_card(idx: int, r: Dict[str, Any], system_label: str, query: str,
243
+ arena_mode: bool, version: str = "") -> str:
244
+ formal_name = r.get("formal_name", "")
245
+ informal_name = r.get("informal_name", "")
246
+ kind = r.get("kind", "")
247
+ type_str = r.get("type", "")
248
+ informal_desc = r.get("informal_description", "")
249
+ path = (r.get("path") or "").strip()
250
+
251
+ formal_escaped = html.escape(formal_name)
252
+ type_escaped = html.escape(type_str)
253
+ query_escaped = html.escape(query)
254
+ system_escaped = html.escape(system_label)
255
+
256
+ doc_url = (
257
+ f"https://leanprover-community.github.io/mathlib4_docs/find/"
258
+ f"?pattern={requests.utils.quote(formal_name)}#doc"
259
+ if formal_name else ""
260
+ )
261
+
262
+ btns: List[str] = []
263
+ if doc_url:
264
+ btns.append(
265
+ f'<button class="card-btn" '
266
+ f'onclick="window.open(\'{doc_url}\', \'_blank\')" '
267
+ f'title="Open in mathlib docs">Doc</button>'
 
 
 
268
  )
269
+ btns.append(
270
+ f'<button class="card-btn" data-copy-text="{formal_escaped}" '
271
+ f'onclick="copyToClipboard(this.getAttribute(\'data-copy-text\'), this)" '
272
+ f'title="Copy name">Copy</button>'
273
+ )
274
+
275
+ vote_cluster = ""
276
+ if arena_mode:
277
+ vote_cluster = (
278
+ f'<div class="vote-cluster">'
279
+ f'<button class="vote-btn upvote" data-formal="{formal_escaped}" '
280
+ f'data-query="{query_escaped}" data-system="{system_escaped}" '
281
+ f'data-rank="{idx}" onclick="voteOnResultSafe(this, \'Upvote\')" '
282
+ f'title="Useful">πŸ‘</button>'
283
+ f'<button class="vote-btn downvote" data-formal="{formal_escaped}" '
284
+ f'data-query="{query_escaped}" data-system="{system_escaped}" '
285
+ f'data-rank="{idx}" onclick="voteOnResultSafe(this, \'Downvote\')" '
286
+ f'title="Not useful">πŸ‘Ž</button>'
287
  f'</div>'
288
  )
289
+
290
+ informal_name_html = (
291
+ f'<div class="informal-name"><span class="math-content">'
292
+ f'{html.escape(informal_name)}</span></div>'
293
+ if informal_name else ""
294
+ )
295
+
296
+ informal_desc_html = ""
297
+ if informal_desc:
298
+ if len(informal_desc) > 320:
299
+ preview = informal_desc[:320].rstrip() + "…"
300
+ informal_desc_html = (
301
+ f'<div class="informal-desc collapsible">'
302
+ f'<span class="math-content desc-preview">{html.escape(preview)}</span>'
303
+ f'<span class="math-content desc-full" hidden>{html.escape(informal_desc)}</span>'
304
+ f'<button class="link-btn read-more" onclick="toggleDesc(this)">Read more</button>'
305
+ f'</div>'
306
+ )
307
+ else:
308
+ informal_desc_html = (
309
+ f'<div class="informal-desc">'
310
+ f'<span class="math-content">{html.escape(informal_desc)}</span>'
311
+ f'</div>'
312
+ )
313
+
314
+ type_section_html = (
315
+ f'<pre class="formal-type"><code>{type_escaped}</code></pre>'
316
+ if type_str else ""
317
+ )
318
+ path_html = ""
319
+ if path:
320
+ path_dotted = html.escape(path.replace("/", "."))
321
+ github_url = f"https://github.com/leanprover-community/mathlib4/tree/{version}/{path}.lean" if version else ""
322
+ if github_url:
323
+ path_html = f'<div class="module-path">In: <a href="{github_url}" target="_blank" class="module-path-link">{path_dotted}</a></div>'
324
+ else:
325
+ path_html = f'<div class="module-path">In: {path_dotted}</div>'
326
+
327
+ return (
328
+ f'<article class="result-card">'
329
+ f' <div class="card-header">'
330
+ f' <span class="rank-badge">{idx}</span>'
331
+ f' {_kind_chip(kind)}'
332
+ f' <code class="formal-name">{formal_escaped}</code>'
333
+ f' <div class="header-actions">{"".join(btns)}{vote_cluster}</div>'
334
+ f' </div>'
335
+ f' {informal_name_html}'
336
+ f' {informal_desc_html}'
337
+ f' {type_section_html}'
338
+ f' {path_html}'
339
+ f'</article>'
340
+ )
341
+
342
+
343
+ def _render_column(results: List[Dict[str, Any]], col_title: str,
344
+ system_label: str, query: str, arena_mode: bool,
345
+ version: str = "", col_css: str = "") -> str:
346
+ if not results:
347
+ body = '<p class="empty">No results.</p>'
348
+ else:
349
+ body = "".join(_result_card(i, r, system_label, query, arena_mode, version)
350
+ for i, r in enumerate(results, 1))
351
+ cls = f'results-column {col_css}'.strip()
352
  return (
353
+ f'<div class="{cls}">'
354
+ f' <div class="col-title">{html.escape(col_title)}</div>'
355
+ f' <div class="cards-stack">{body}</div>'
356
+ f'</div>'
357
  )
358
 
359
+
360
+ def _render_single(results: List[Dict[str, Any]], query: str, version: str = "") -> str:
361
+ if not results:
362
+ return '<div class="empty-state">No matching Lean statements found.</div>'
363
+ cards = "".join(_result_card(i, r, LEAN_FINDER, query, arena_mode=False, version=version)
364
+ for i, r in enumerate(results, 1))
365
+ return f'<div class="results-single"><div class="cards-stack">{cards}</div></div>'
366
+
367
+
368
+ def _render_arena(ret_a: List[Dict[str, Any]], ret_b: List[Dict[str, Any]],
369
+ a_source: str, b_source: str, query: str, version: str = "") -> str:
370
+ a_html = _render_column(ret_a, "Retriever A", a_source, query, arena_mode=True, version=version, col_css="column-a")
371
+ b_html = _render_column(ret_b, "Retriever B", b_source, query, arena_mode=True, version=version, col_css="column-b")
372
+ return f'<div class="arena-grid">{a_html}{b_html}</div>'
373
+
374
+
375
+ # Static content
376
+
377
  INSTRUCTIONS_MD = """
378
+ ### Lean Finder accepts four kinds of queries
379
+
380
+ 1. **Informalized statement** β€” A natural-language rendering of a Lean statement.
381
+ > *Let L/K be a field extension and let x, y ∈ L be algebraic elements over K with the same minimal polynomial. Then the K-algebra isomorphism algEquiv between the simple field extensions K(x) and K(y) maps the generator x of K(x) to the generator y of K(y); i.e. algEquiv(x) = y.*
382
+
383
+ 2. **User question** β€” Free-form questions about Lean / Mathlib.
384
+ > *I'm working with algebraic elements over a field extension … Does this imply that the minimal polynomials of `x` and `y` are equal?*
385
+
386
+ 3. **Proof state** β€” Paste a proof state, optionally followed by what you want to do next.
387
+ > *K : Type u_1, E : Type u_2, inst✝ : RCLike K, z✝ z : K ⊒ |re z| ≀ β€–zβ€–. Transform the goal from proving that the absolute value of the real part is ≀ the norm, to proving that the square of the real part is ≀ the squared norm.*
388
+
389
+ 4. **Statement fragment** β€” A snippet or rough draft of the Lean definition you're looking for.
390
+ > *theorem restrict Ioi: restrict Ioi e = restrict Ici e*
391
+
392
+ **Mathlib version** β€” pick from `v4.19.0`, `v4.24.0`, or `v4.28.0`. Lean Finder
393
+ queries the index matching that version so the formal names it returns actually
394
+ exist in your build.
 
 
 
 
 
 
 
 
 
 
 
 
395
  """
396
 
397
+
398
  CUSTOM_CSS = """
399
+ :root {
400
+ --bg: #ffffff;
401
+ --fg: #111827;
402
+ --muted: #6b7280;
403
+ --border: #e5e7eb;
404
+ --card-bg: #ffffff;
405
+ --card-border: #e5e7eb;
406
+ --card-hover-border: #c7d2fe;
407
+ --primary: #4f46e5;
408
+ --primary-2: #7c3aed;
409
+ --primary-soft: #eef2ff;
410
+ --code-bg: #f9fafb;
411
+ --success: #10b981;
412
+ --danger: #ef4444;
413
  }
414
 
415
+ @media (prefers-color-scheme: dark) {
416
+ :root {
417
+ --bg: #0b0b0d;
418
+ --fg: #e5e7eb;
419
+ --muted: #9ca3af;
420
+ --border: #27272a;
421
+ --card-bg: #141417;
422
+ --card-border: #27272a;
423
+ --card-hover-border: #4f46e5;
424
+ --primary: #818cf8;
425
+ --primary-2: #a78bfa;
426
+ --primary-soft: #1e1b4b;
427
+ --code-bg: #0f0f12;
428
+ --success: #34d399;
429
+ --danger: #f87171;
430
+ }
431
+ }
432
+ .dark {
433
+ --bg: #0b0b0d;
434
+ --fg: #e5e7eb;
435
+ --muted: #9ca3af;
436
+ --border: #27272a;
437
+ --card-bg: #141417;
438
+ --card-border: #27272a;
439
+ --card-hover-border: #4f46e5;
440
+ --primary: #818cf8;
441
+ --primary-2: #a78bfa;
442
+ --primary-soft: #1e1b4b;
443
+ --code-bg: #0f0f12;
444
+ --success: #34d399;
445
+ --danger: #f87171;
446
+ }
447
 
448
+ html, body { margin: 0; padding: 0; width: 100%; }
449
+ .gradio-container, .gradio-container .block {
450
+ max-width: 1280px !important;
451
+ margin: 0 auto !important;
452
+ padding: 0 1rem;
 
 
453
  }
454
 
455
+ /* Hero β€” 3-column grid keeps the logo+title centered while the badge floats right */
456
+ .lf-hero {
457
+ display: grid;
458
+ grid-template-columns: 1fr auto 1fr;
459
+ align-items: center;
460
+ gap: 1rem;
461
+ padding: 1.6rem 1.4rem;
462
+ margin: 0.5rem 0 1.1rem;
463
+ background: linear-gradient(135deg, rgba(102,126,234,0.08) 0%, rgba(118,75,162,0.08) 100%);
464
+ border: 1px solid var(--border);
465
+ border-radius: 18px;
466
+ }
467
+ .lf-hero-main {
468
+ grid-column: 2;
469
+ display: flex;
470
+ align-items: center;
471
+ gap: 1.1rem;
472
+ min-width: 0;
473
+ }
474
+ .lf-hero .lf-logo {
475
+ width: 76px; height: 76px; flex: 0 0 76px;
476
+ border-radius: 14px;
477
+ object-fit: contain;
478
+ background: white;
479
+ padding: 8px;
480
+ box-shadow: 0 2px 10px rgba(0,0,0,0.06);
481
+ }
482
+ .lf-hero-text { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
483
+ .lf-title {
484
+ margin: 0 !important;
485
+ padding: 0 !important;
486
+ font-size: clamp(2rem, 4.5vw, 2.85rem) !important;
487
+ font-weight: 800 !important;
488
+ line-height: 1.1 !important;
489
+ letter-spacing: -0.02em !important;
490
+ color: #4f46e5; /* fallback for browsers that don't support gradient text */
491
+ background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
492
+ -webkit-background-clip: text;
493
+ background-clip: text;
494
+ -webkit-text-fill-color: transparent;
495
+ display: inline-block;
496
+ }
497
  @media (prefers-color-scheme: dark) {
498
+ .lf-title {
499
+ color: #a78bfa;
500
+ background: linear-gradient(135deg, #818cf8 0%, #c4b5fd 100%);
501
+ -webkit-background-clip: text;
502
+ background-clip: text;
503
+ -webkit-text-fill-color: transparent;
 
 
 
504
  }
505
  }
506
+ .dark .lf-title {
507
+ color: #a78bfa;
508
+ background: linear-gradient(135deg, #818cf8 0%, #c4b5fd 100%);
509
+ -webkit-background-clip: text;
510
+ background-clip: text;
511
+ -webkit-text-fill-color: transparent;
512
+ }
513
+ .lf-tagline {
514
+ margin: 0.35rem 0 0 0;
515
+ color: var(--muted);
516
+ font-size: 0.92rem;
517
+ font-weight: 400;
518
+ line-height: 1.4;
519
+ }
520
+ .lf-hero-badge {
521
+ grid-column: 3;
522
+ justify-self: end;
523
+ display: inline-flex;
524
+ align-items: center;
525
+ gap: 0.4rem;
526
+ padding: 0.5rem 0.95rem;
527
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
528
+ color: white;
529
+ text-decoration: none;
530
+ border-radius: 999px;
531
+ font-weight: 500;
532
+ font-size: 0.82rem;
533
+ box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
534
+ transition: transform .15s ease, box-shadow .15s ease;
535
+ white-space: nowrap;
536
+ }
537
+ .lf-hero-badge:hover {
538
+ transform: translateY(-1px);
539
+ box-shadow: 0 6px 16px rgba(102, 126, 234, 0.35);
540
+ }
541
+ @media (max-width: 760px) {
542
+ .lf-hero {
543
+ grid-template-columns: 1fr;
544
+ text-align: center;
545
+ gap: 0.75rem;
546
+ }
547
+ .lf-hero-main { grid-column: 1; flex-direction: column; }
548
+ .lf-hero-badge { grid-column: 1; justify-self: center; }
549
  }
550
 
551
+ /* Query input */
552
+ #query_input textarea {
553
+ resize: vertical !important;
554
+ min-height: 120px !important;
555
+ max-height: 360px !important;
556
+ font-size: 0.95rem !important;
557
+ line-height: 1.5 !important;
558
+ padding: 12px !important;
559
+ border-radius: 10px !important;
560
  }
561
 
562
+ /* Controls row β€” keep compact, even baseline */
563
+ #controls_row { gap: 0.75rem; align-items: flex-end; margin-top: 0.4rem; }
564
+ #controls_row label { font-size: 0.8rem !important; font-weight: 500 !important; color: var(--muted) !important; }
565
+ #controls_row label.selected, #controls_row label.selected span { color: white !important; }
566
+ #controls_row .gr-form { background: transparent !important; border: none !important; }
567
+
568
+ /* Search button - the only purple gradient in this region */
569
+ #search_btn { margin: 0.9rem 0 0.6rem; }
570
+ #search_btn button {
571
+ background: linear-gradient(135deg, var(--primary) 0%, var(--primary-2) 100%) !important;
572
+ color: white !important;
573
+ border: none !important;
574
+ padding: 0.85rem 1.4rem !important;
575
+ font-weight: 600 !important;
576
+ font-size: 1rem !important;
577
+ border-radius: 12px !important;
578
+ box-shadow: 0 4px 14px rgba(79, 70, 229, 0.28) !important;
579
+ transition: transform .12s ease, box-shadow .15s ease !important;
580
+ width: 100% !important;
581
  }
582
+ #search_btn button:hover {
583
+ transform: translateY(-1px) !important;
584
+ box-shadow: 0 8px 20px rgba(79, 70, 229, 0.38) !important;
585
  }
586
 
587
+ #mode_description { color: var(--muted); font-size: 0.85rem; margin: 0.2rem 0 0.4rem; }
588
+ #mode_description p { margin: 0; }
589
+
590
+ /* Result cards */
591
+ .results-single, .arena-grid { margin-top: 1rem; }
592
+ .arena-grid {
593
+ display: grid;
594
+ grid-template-columns: 1fr 1fr;
595
+ gap: 1rem;
596
+ }
597
+ @media (max-width: 860px) {
598
+ .arena-grid { grid-template-columns: 1fr; }
599
  }
600
 
601
+ .results-column { display: flex; flex-direction: column; min-width: 0; }
602
 
603
+ /* Vertical divider between arena columns */
604
+ .results-column.column-b { border-left: 2px solid #6366f1; padding-left: 1rem; }
605
+ @media (prefers-color-scheme: dark) {
606
+ .results-column.column-b { border-left-color: #818cf8; }
 
 
 
 
 
607
  }
608
+ .dark .results-column.column-b { border-left-color: #818cf8; }
609
 
610
+ /* Subtle, non-gradient column titles */
611
+ .col-title {
612
+ font-size: 0.78rem;
613
+ margin: 0 0 0.7rem 0;
614
+ padding: 0.4rem 0;
615
+ color: var(--muted);
616
+ font-weight: 600;
617
+ text-align: center;
618
+ letter-spacing: 0.12em;
619
+ text-transform: uppercase;
620
+ border-bottom: 1px solid var(--border);
621
  }
622
 
623
+ .cards-stack { display: flex; flex-direction: column; gap: 0.7rem; }
 
 
 
 
 
624
 
625
+ .result-card {
626
+ background: var(--card-bg);
627
+ border: 1px solid var(--card-border);
628
+ border-radius: 12px;
629
+ padding: 0.85rem 1rem;
630
+ transition: border-color .15s ease, box-shadow .15s ease, transform .12s ease;
631
+ box-shadow: 0 1px 2px rgba(0,0,0,0.04);
632
+ min-width: 0;
633
+ }
634
+ .result-card:hover {
635
+ border-color: var(--card-hover-border);
636
+ box-shadow: 0 4px 14px rgba(79, 70, 229, 0.08);
637
  }
638
 
639
+ .card-header {
640
+ display: flex;
641
+ align-items: center;
642
+ gap: 0.55rem;
643
+ min-width: 0;
644
+ }
645
 
646
+ .rank-badge {
647
+ display: inline-flex;
648
+ align-items: center;
649
+ justify-content: center;
650
+ width: 1.6rem;
651
+ height: 1.6rem;
652
+ border-radius: 50%;
653
+ background: var(--primary-soft);
654
+ color: var(--primary);
655
+ font-weight: 700;
656
+ font-size: 0.78rem;
657
+ flex-shrink: 0;
658
  }
659
 
660
+ .kind-chip {
661
+ font-size: 0.65rem;
662
+ font-weight: 700;
663
+ padding: 0.18rem 0.55rem;
664
+ border-radius: 999px;
665
+ letter-spacing: 0.04em;
666
+ text-transform: uppercase;
667
+ line-height: 1.4;
 
668
  }
669
 
670
+ code.formal-name {
671
+ background: transparent;
672
+ border: none;
673
+ color: var(--fg);
674
  font-weight: 600;
675
+ font-size: 0.92rem;
676
+ padding: 0;
677
+ word-break: break-all;
678
+ flex: 1 1 0;
679
+ min-width: 0;
680
+ font-family: 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, 'Courier New', monospace;
681
  }
682
 
683
+ .module-path {
684
+ margin-top: 0.3rem;
685
+ font-size: 0.75rem;
686
+ color: var(--muted);
687
+ font-family: 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace;
688
+ opacity: 0.75;
689
+ letter-spacing: 0.01em;
690
+ }
691
+ .module-path-link {
692
+ color: var(--primary);
693
+ opacity: 0.55;
694
+ text-decoration: underline;
695
+ text-decoration-color: rgba(79, 70, 229, 0.25);
696
+ text-underline-offset: 2px;
697
+ transition: opacity .12s ease, text-decoration-color .12s ease;
698
+ }
699
+ .module-path-link:hover {
700
+ opacity: 0.85;
701
+ text-decoration-color: var(--primary);
702
  }
703
 
704
+ .header-actions {
705
+ display: flex;
706
+ gap: 0.3rem;
707
+ margin-left: auto;
708
+ align-items: center;
709
+ flex-wrap: wrap;
 
710
  }
711
 
712
+ .card-btn {
713
+ display: inline-flex;
714
+ align-items: center;
715
+ justify-content: center;
716
+ gap: 0.35rem;
717
+ height: 32px;
718
+ padding: 0 0.8rem;
719
+ background: var(--bg);
720
+ border: 1px solid var(--border);
721
+ color: var(--muted);
722
+ border-radius: 7px;
723
+ font-size: 0.82rem;
724
+ line-height: 1;
725
  cursor: pointer;
726
+ transition: all .12s ease;
 
 
 
727
  white-space: nowrap;
728
+ font-family: inherit;
729
+ font-weight: 500;
730
+ vertical-align: middle;
731
  }
732
+ .card-btn:hover {
733
+ border-color: var(--primary);
734
+ color: var(--primary);
735
+ background: var(--primary-soft);
736
  }
737
 
738
+ .vote-cluster {
739
+ display: inline-flex;
740
+ align-items: center;
741
+ gap: 0.25rem;
742
+ margin-left: 0.4rem;
743
+ padding-left: 0.55rem;
744
+ border-left: 1px solid var(--border);
745
  }
746
 
747
+ .vote-btn {
748
+ display: inline-flex;
749
+ align-items: center;
750
+ justify-content: center;
751
+ height: 28px;
752
+ min-width: 36px;
753
+ padding: 0 0.4rem;
754
+ background: var(--bg);
755
+ border: 1px solid var(--border);
756
+ color: var(--muted);
757
+ border-radius: 7px;
758
+ font-size: 0.95rem;
759
+ line-height: 1;
760
+ cursor: pointer;
761
+ transition: all .12s ease;
762
+ vertical-align: middle;
763
  }
764
+ .vote-btn:hover { transform: translateY(-1px); }
765
+ .vote-btn.upvote:hover { border-color: var(--success); color: var(--success); background: rgba(16,185,129,0.08); }
766
+ .vote-btn.downvote:hover { border-color: var(--danger); color: var(--danger); background: rgba(239,68,68,0.08); }
767
+ .vote-btn.voted.upvote { background: var(--success); color: white; border-color: var(--success); }
768
+ .vote-btn.voted.downvote { background: var(--danger); color: white; border-color: var(--danger); }
769
+
770
+ .informal-name {
771
+ margin-top: 0.55rem;
772
+ font-size: 0.92rem;
773
+ color: var(--fg);
774
+ font-weight: 500;
775
+ line-height: 1.45;
776
  }
777
 
778
+ .informal-desc {
779
+ margin-top: 0.4rem;
780
+ font-size: 0.86rem;
781
+ line-height: 1.55;
782
+ color: var(--muted);
783
  }
784
 
785
+ /* Dark-mode: ensure informal text is bright enough on dark card backgrounds */
786
+ @media (prefers-color-scheme: dark) {
787
+ .informal-name { color: #e5e7eb; }
788
+ .informal-desc { color: #a1a1aa; }
 
789
  }
790
+ .dark .informal-name { color: #e5e7eb; }
791
+ .dark .informal-desc { color: #a1a1aa; }
792
+ .informal-desc.collapsible .desc-full[hidden] { display: none; }
793
+ .link-btn {
794
+ background: none;
795
+ border: none;
796
+ color: var(--primary);
797
+ cursor: pointer;
798
+ font-size: 0.78rem;
799
+ font-weight: 500;
800
+ padding: 0 0 0 4px;
801
+ text-decoration: underline;
802
+ font-family: inherit;
803
  }
804
+ .link-btn:hover { text-decoration: none; }
805
+
806
+ /* Formal type β€” always visible, calm look */
807
+ .formal-type {
808
+ background: var(--code-bg);
809
+ border: 1px solid var(--border);
810
+ border-radius: 8px;
811
+ padding: 0.65rem 0.8rem;
812
+ margin: 0.7rem 0 0;
813
+ overflow-x: auto;
814
+ font-size: 0.78rem;
815
+ line-height: 1.5;
816
+ }
817
+ .formal-type code {
818
  background: transparent;
819
+ border: none;
820
+ padding: 0;
821
+ white-space: pre-wrap;
822
+ word-break: break-word;
823
+ color: var(--fg);
824
+ font-family: 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace;
 
 
 
 
825
  }
826
 
827
+ .empty {
828
+ color: var(--muted);
829
+ text-align: center;
830
+ padding: 1rem;
831
+ font-size: 0.9rem;
832
  }
833
+ .empty-state {
834
+ text-align: center;
835
+ padding: 2.5rem 1rem;
836
+ color: var(--muted);
837
+ border: 1px dashed var(--border);
838
+ border-radius: 12px;
839
+ margin-top: 0.75rem;
840
  }
841
 
842
+ /* Vote area β€” warm accent so it reads as a separate "action" zone */
843
+ #vote_area {
844
+ margin: 1rem 0 0.3rem !important;
845
+ padding: 0.95rem 1.1rem !important;
846
+ border: 1px solid rgba(245, 158, 11, 0.35) !important;
847
+ border-radius: 14px !important;
848
+ background: linear-gradient(180deg, rgba(254, 243, 199, 0.55) 0%, rgba(254, 243, 199, 0.12) 100%) !important;
849
+ box-shadow: 0 1px 2px rgba(0,0,0,0.03);
850
  }
851
+ @media (prefers-color-scheme: dark) {
852
+ #vote_area {
853
+ background: linear-gradient(180deg, rgba(120, 53, 15, 0.22) 0%, rgba(120, 53, 15, 0.05) 100%) !important;
854
+ border-color: rgba(245, 158, 11, 0.30) !important;
855
+ }
856
+ }
857
+ .dark #vote_area {
858
+ background: linear-gradient(180deg, rgba(120, 53, 15, 0.22) 0%, rgba(120, 53, 15, 0.05) 100%) !important;
859
+ border-color: rgba(245, 158, 11, 0.30) !important;
860
+ }
861
+ .vote-area-label {
862
+ font-size: 0.78rem;
863
+ font-weight: 700;
864
+ color: #92400e;
865
+ letter-spacing: 0.08em;
866
+ text-transform: uppercase;
867
+ margin-bottom: 0.55rem;
868
+ }
869
+ @media (prefers-color-scheme: dark) {
870
+ .vote-area-label { color: #fbbf24; }
871
+ }
872
+ .dark .vote-area-label { color: #fbbf24; }
873
+ #vote_radio .gr-radio { display: flex; gap: 0.45rem; flex-wrap: wrap; }
874
+ #vote_radio label {
875
+ padding: 6px 14px;
876
+ border: 1px solid var(--border);
877
+ background: var(--card-bg);
878
+ border-radius: 8px;
879
  cursor: pointer;
880
+ user-select: none;
881
+ transition: all .15s ease;
882
+ font-size: 0.86rem;
883
+ color: var(--fg);
 
 
 
884
  }
885
+ @media (prefers-color-scheme: dark) {
886
+ #vote_radio label { color: #e5e7eb; background: #1c1c20; border-color: #3f3f46; }
887
+ }
888
+ .dark #vote_radio label { color: #e5e7eb; background: #1c1c20; border-color: #3f3f46; }
889
+ #vote_radio input[type="radio"]:checked + label {
890
+ background: #f59e0b;
891
  color: white;
892
+ border-color: #f59e0b;
893
  }
894
 
895
+ /* Dark-mode kind chips β€” boost contrast */
896
+ @media (prefers-color-scheme: dark) {
897
+ .kind-chip { filter: brightness(1.15) saturate(1.1); }
 
 
 
898
  }
899
+ .dark .kind-chip { filter: brightness(1.15) saturate(1.1); }
900
+ #submit_btn button {
901
+ padding: 0.55rem 1.4rem !important;
902
+ font-weight: 600 !important;
903
+ font-size: 0.9rem !important;
904
+ border-radius: 8px !important;
905
+ background: #f59e0b !important;
906
+ color: white !important;
907
+ border: none !important;
908
+ margin-top: 0.5rem !important;
909
+ width: auto !important;
910
+ align-self: flex-start;
911
+ box-shadow: 0 2px 8px rgba(245, 158, 11, 0.25) !important;
912
  }
913
+ #submit_btn button:hover {
914
+ background: #d97706 !important;
915
+ box-shadow: 0 4px 12px rgba(245, 158, 11, 0.35) !important;
 
 
916
  }
917
+ #vote_status { margin-top: 0.5rem; text-align: center; }
918
+ #vote_trigger_input { display: none !important; }
919
 
920
+ /* Misc */
921
+ .gr-accordion-header {
922
+ font-size: 1.0rem;
923
+ font-weight: 600;
924
+ cursor: pointer;
925
  }
926
 
927
+ /* Progress overlay center */
928
+ div.gradio-modal[aria-label="progress"] {
929
+ position: fixed !important;
930
+ top: 50% !important; left: 50% !important;
931
+ transform: translate(-50%, -50%) !important;
932
+ z-index: 2000 !important;
933
+ width: clamp(260px, 70vw, 440px) !important;
934
+ max-height: 160px !important;
935
+ padding: 20px 24px !important;
936
+ background: var(--card-bg) !important;
937
+ border: 1px solid var(--border) !important;
938
+ border-radius: 12px !important;
939
+ box-shadow: 0 8px 28px rgba(0,0,0,.18) !important;
940
  }
941
 
942
+ /* Acknowledgements */
943
+ .acknowledgements {
944
+ margin-top: 2rem;
945
+ padding: 1rem 0 0.5rem;
946
+ border-top: 1px solid var(--border);
947
+ text-align: center;
948
+ }
949
+ .ack-title {
950
+ font-size: 0.72rem;
951
+ font-weight: 600;
952
+ text-transform: uppercase;
953
+ letter-spacing: 0.1em;
954
+ color: var(--muted);
955
+ margin-bottom: 0.35rem;
956
+ }
957
+ .ack-body {
958
+ font-size: 0.78rem;
959
+ color: var(--muted);
960
+ line-height: 1.6;
961
+ }
962
+ .ack-body a {
963
+ color: var(--primary);
964
+ text-decoration: none;
965
+ opacity: 0.75;
966
+ transition: opacity .12s ease;
967
+ }
968
+ .ack-body a:hover {
969
  opacity: 1;
970
+ text-decoration: underline;
971
  }
972
+ """
973
 
 
 
 
 
974
 
975
+ HEAD_HTML = """
976
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css">
977
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
978
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.js"></script>
979
+ <style>
980
+ body, .gradio-container { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
981
+ </style>
982
+ <script>
983
+ function renderKatex() {
984
+ if (typeof katex === 'undefined') { setTimeout(renderKatex, 400); return; }
985
+ document.querySelectorAll('.math-content:not([data-katex-processed])').forEach(function(el) {
986
+ el.setAttribute('data-katex-processed', 'true');
987
+ const text = el.textContent;
988
+ if (!text || (text.indexOf('$') === -1 && text.indexOf('\\\\(') === -1)) return;
989
+ let html = '';
990
+ let i = 0;
991
+ while (i < text.length) {
992
+ if (text[i] === '$' && text[i+1] === '$') {
993
+ const end = text.indexOf('$$', i + 2);
994
+ if (end === -1) { html += text.substring(i); break; }
995
+ try {
996
+ html += katex.renderToString(text.substring(i + 2, end), { throwOnError: false, displayMode: true });
997
+ } catch (e) { html += '$$' + escapeHtml(text.substring(i + 2, end)) + '$$'; }
998
+ i = end + 2;
999
+ } else if (text[i] === '$') {
1000
+ const end = text.indexOf('$', i + 1);
1001
+ if (end === -1) { html += escapeHtml(text.substring(i)); break; }
1002
+ try {
1003
+ html += katex.renderToString(text.substring(i + 1, end), { throwOnError: false });
1004
+ } catch (e) { html += '$' + escapeHtml(text.substring(i + 1, end)) + '$'; }
1005
+ i = end + 1;
1006
+ } else {
1007
+ html += escapeHtml(text[i]);
1008
+ i++;
1009
+ }
1010
+ }
1011
+ el.innerHTML = html;
1012
+ });
1013
  }
1014
+ function escapeHtml(s) { return s.replace(/[&<>"']/g, function(c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; }); }
1015
+ setInterval(renderKatex, 1200);
1016
+
1017
+ function copyToClipboard(text, button) {
1018
+ const ok = (b) => {
1019
+ const t = b.innerHTML; b.innerHTML = 'βœ“ Copied'; b.style.color = '#10b981';
1020
+ setTimeout(() => { b.innerHTML = t; b.style.color = ''; }, 1200);
1021
+ };
1022
+ if (navigator.clipboard && window.isSecureContext) {
1023
+ navigator.clipboard.writeText(text).then(() => ok(button)).catch(() => fb(text, button));
1024
+ } else { fb(text, button); }
1025
+ function fb(t, b) {
1026
+ const ta = document.createElement('textarea');
1027
+ ta.value = t; ta.style.position = 'fixed'; ta.style.left = '-9999px';
1028
+ document.body.appendChild(ta); ta.select();
1029
+ try { document.execCommand('copy'); ok(b); } catch(e) {}
1030
+ document.body.removeChild(ta);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1031
  }
1032
+ }
1033
+
1034
+ function toggleDesc(btn) {
1035
+ const parent = btn.parentElement;
1036
+ const preview = parent.querySelector('.desc-preview');
1037
+ const full = parent.querySelector('.desc-full');
1038
+ const showingFull = !full.hasAttribute('hidden');
1039
+ if (showingFull) {
1040
+ full.setAttribute('hidden', '');
1041
+ preview.removeAttribute('hidden');
1042
+ btn.textContent = 'Read more';
1043
+ } else {
1044
+ full.removeAttribute('hidden');
1045
+ preview.setAttribute('hidden', '');
1046
+ btn.textContent = 'Show less';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1047
  }
1048
+ }
1049
+
1050
+ function voteOnResultSafe(button, voteDecision) {
1051
+ const formal = button.getAttribute('data-formal');
1052
+ const query = button.getAttribute('data-query');
1053
+ const system = button.getAttribute('data-system');
1054
+ const rank = parseInt(button.getAttribute('data-rank'));
1055
+ button.classList.add('voted');
1056
+ const sibling = button.parentElement.querySelector(
1057
+ '.vote-btn.' + (voteDecision === 'Upvote' ? 'downvote' : 'upvote')
1058
+ );
1059
+ if (sibling) { sibling.style.opacity = '0.35'; sibling.style.pointerEvents = 'none'; }
1060
+ const data = JSON.stringify({
1061
+ formal_statement: formal, vote_decision: voteDecision,
1062
+ query: query, system: system, rank: rank
1063
+ });
1064
+ const container = document.querySelector('#vote_trigger_input');
1065
+ if (container) {
1066
+ const input = container.querySelector('textarea') || container.querySelector('input[type="text"]');
1067
+ if (input) {
1068
+ input.value = data;
1069
+ input.dispatchEvent(new Event('input', { bubbles: true }));
1070
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1071
+ setTimeout(function() {
1072
+ const inp = container.querySelector('textarea') || container.querySelector('input[type="text"]');
1073
+ if (inp) { inp.value=''; inp.dispatchEvent(new Event('input',{bubbles:true})); }
1074
+ }, 2500);
1075
  }
1076
  }
1077
  }
 
1078
 
1079
+ /* --- Auto-scroll to vote area after search results load --- */
1080
+ function lf_scrollToVote() {
1081
+ const voteArea = document.querySelector('#vote_area');
1082
+ const resultsEl = document.querySelector('.arena-grid') || document.querySelector('.results-single');
1083
+ const target = voteArea && voteArea.offsetParent !== null ? voteArea : resultsEl;
1084
+ if (target) {
1085
+ setTimeout(function() {
1086
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
1087
+ }, 120);
1088
+ }
1089
+ }
1090
+ // Observe the results HTML container for changes (Gradio replaces innerHTML)
1091
+ (function() {
1092
+ const observer = new MutationObserver(function(mutations) {
1093
+ for (const m of mutations) {
1094
+ if (m.type === 'childList' && m.addedNodes.length) {
1095
+ const container = m.target.closest ? m.target : m.target.parentElement;
1096
+ if (container && (container.querySelector('.arena-grid') || container.querySelector('.results-single'))) {
1097
+ lf_scrollToVote();
1098
+ break;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1099
  }
1100
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1101
  }
1102
+ });
1103
+ // Start observing once Gradio has mounted
1104
+ function startObserving() {
1105
+ const app = document.querySelector('.gradio-container');
1106
+ if (app) {
1107
+ observer.observe(app, { childList: true, subtree: true });
1108
+ } else {
1109
+ setTimeout(startObserving, 500);
 
 
 
 
 
 
 
 
 
1110
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1111
  }
1112
+ startObserving();
1113
+ })();
1114
+
1115
+ /* --- Enter key to search --- */
1116
+ (function() {
1117
+ function attachEnter() {
1118
+ const container = document.querySelector('#query_input');
1119
+ if (!container) { setTimeout(attachEnter, 500); return; }
1120
+ const textarea = container.querySelector('textarea');
1121
+ if (!textarea) { setTimeout(attachEnter, 500); return; }
1122
+ textarea.addEventListener('keydown', function(e) {
1123
+ // Enter (without Shift) triggers search
1124
+ if (e.key === 'Enter' && !e.shiftKey) {
1125
+ e.preventDefault();
1126
+ const btn = document.querySelector('#search_btn button');
1127
+ if (btn) btn.click();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1128
  }
1129
+ });
1130
  }
1131
+ if (document.readyState === 'loading') {
1132
+ document.addEventListener('DOMContentLoaded', attachEnter);
1133
+ } else {
1134
+ attachEnter();
 
 
 
 
 
 
1135
  }
1136
+ })();
1137
+ </script>
1138
+ """
1139
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1140
 
1141
+ with gr.Blocks(title="Lean Finder β€” Semantic Search for Mathlib",
1142
+ css=CUSTOM_CSS, head=HEAD_HTML, theme=gr.themes.Soft(
1143
+ primary_hue="indigo",
1144
+ neutral_hue="slate",
1145
+ )) as demo:
1146
+
1147
+ gr.HTML(
1148
+ '<div class="lf-hero">'
1149
+ ' <div class="lf-hero-main">'
1150
+ ' <img class="lf-logo" src="/file=lean_finder_logo.png" alt="Lean Finder logo">'
1151
+ ' <div class="lf-hero-text">'
1152
+ ' <div class="lf-title">Lean Finder</div>'
1153
+ ' <div class="lf-tagline">Semantic Search For Mathlib That Understands User Intents</div>'
1154
+ ' </div>'
1155
+ ' </div>'
1156
+ ' <a class="lf-hero-badge" href="https://arxiv.org/pdf/2510.15940" target="_blank">'
1157
+ ' Paper Link'
1158
+ ' </a>'
1159
+ '</div>'
1160
+ )
1161
+
1162
+ with gr.Accordion("πŸ“˜ What can I search for? (4 query types + Mathlib versions)", open=False):
1163
  gr.Markdown(INSTRUCTIONS_MD)
1164
 
1165
+ query_box = gr.Textbox(
1166
+ label="Your query",
1167
+ lines=4, max_lines=18,
1168
+ placeholder="Describe the Lean statement you're looking for, ask a question, paste a proof state, or sketch a definition…",
1169
+ elem_id="query_input",
1170
+ show_copy_button=True,
1171
+ )
1172
+
1173
+ with gr.Row(elem_id="controls_row", equal_height=True):
1174
+ version_sel = gr.Dropdown(
1175
+ MATHLIB_VERSIONS,
1176
+ value=DEFAULT_VERSION,
1177
+ label="Mathlib version",
1178
+ scale=1, min_width=140, container=True,
1179
+ )
1180
+ topk_slider = gr.Slider(
1181
+ label="Results",
1182
+ minimum=1, maximum=20, step=1, value=5,
1183
+ scale=2, min_width=180,
1184
+ )
1185
+ mode_sel = gr.Radio(
1186
+ ["Arena", "Normal"], value="Arena", label="Mode",
1187
+ scale=2, min_width=180,
1188
+ )
1189
 
1190
+ run_btn = gr.Button("Search", elem_id="search_btn", variant="primary")
1191
 
1192
+ mode_description = gr.Markdown(
1193
+ "_**Arena mode** β€” Lean Finder is compared side-by-side with another model. "
1194
+ "The two models are shown as **Retriever A** and **Retriever B** in random order so the comparison stays fair. "
1195
+ "Vote for the better set overall, or πŸ‘/πŸ‘Ž individual results. "
1196
+ "Your votes help us improve Lean Finder β€” no personally identifiable information is stored._",
1197
+ elem_id="mode_description",
1198
+ )
1199
 
1200
+ with gr.Row(elem_id="vote_area", visible=False) as vote_row:
 
 
 
 
1201
  with gr.Column():
1202
+ gr.HTML('<div class="vote-area-label">Cast your vote</div>')
 
 
 
 
 
 
1203
  vote_radio = gr.Radio(
1204
  VOTE_UI,
1205
+ label="Which set of results did better overall?",
1206
+ elem_id="vote_radio",
 
1207
  )
1208
  submit_btn = gr.Button(
1209
  "Submit vote",
 
1210
  elem_id="submit_btn",
 
1211
  )
1212
  vote_status = gr.Markdown("", visible=False, elem_id="vote_status")
1213
 
1214
+ results_html = gr.HTML(
1215
+ '<div class="empty-state">Type a query above and hit <b>Search</b> '
1216
+ 'to see Lean statements from Mathlib.</div>'
1217
+ )
1218
+
1219
+ # Hidden vote bridge
1220
+ vote_trigger = gr.Textbox(visible=True, elem_id="vote_trigger_input")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1221
  vote_feedback = gr.Markdown("", visible=True, elem_id="vote_feedback")
1222
 
1223
+ gr.HTML(
1224
+ '<div class="acknowledgements">'
1225
+ ' <div class="ack-title">Acknowledgements</div>'
1226
+ ' <div class="ack-body">'
1227
+ ' Lean Finder is built on open-source projects like '
1228
+ ' <a href="https://github.com/frenzymath/jixia" target="_blank">Jixia</a> and '
1229
+ ' <a href="https://github.com/lean-dojo/leandojo" target="_blank">LeanDojo</a>. '
1230
+ ' We were inspired by '
1231
+ ' <a href="https://leansearch.net/" target="_blank">LeanSearch</a>, and we '
1232
+ ' use LeanSearch in our Arena mode to let users compare results. '
1233
+ ' Thanks to the creators of these awesome tools!'
1234
+ ' </div>'
1235
+ '</div>'
1236
+ )
1237
+
1238
+ # Session state
1239
  st_query = gr.State("")
1240
+ st_version = gr.State(DEFAULT_VERSION)
1241
+ st_ret_a = gr.State([])
1242
+ st_ret_b = gr.State([])
1243
+ st_a_source = gr.State(LEAN_FINDER)
1244
+ st_b_source = gr.State(LEAN_SEARCH)
1245
+
1246
+ def retrieve(query: str, k: int, version: str, mode: str, progress=gr.Progress()):
1247
+ query = (query or "").strip()
1248
+ hide_radio = gr.update(visible=False, value=None)
1249
+ hide_status = gr.update(visible=False, value="")
1250
+ hide_row = gr.update(visible=False)
1251
 
 
 
1252
  if not query:
1253
+ return (
1254
+ '<div class="empty-state">Please enter a query.</div>',
1255
+ "", version, [], [], LEAN_FINDER, LEAN_SEARCH,
1256
+ hide_radio, hide_status, hide_row,
1257
+ )
1258
 
1259
+ # Call Lean Finder, with cold-start retry loop preserved
1260
  try:
1261
+ lf_raw = _call_lean_finder(query, k, version)
1262
  except RuntimeError:
1263
+ progress(0, desc="Lean Finder is warming up. Results will appear in ~1–2 min.")
1264
+ start = time.time()
1265
+ lf_raw = None
1266
+ timeout_s = 300
1267
+ phase1 = 120
1268
+ while time.time() - start < timeout_s:
1269
+ elapsed = time.time() - start
1270
+ if elapsed < phase1:
1271
+ progress(elapsed / phase1, desc="Lean Finder is warming up. Results will appear in ~1–2 min.")
1272
+ else:
1273
+ progress(1.0, desc="Slow cold start β€” almost ready…")
 
 
 
 
 
 
 
 
1274
  try:
1275
+ lf_raw = _call_lean_finder(query, k, version)
1276
  break
1277
  except RuntimeError:
1278
+ time.sleep(2)
1279
+ if lf_raw is None:
1280
+ err = (
1281
+ '<div class="empty-state" style="color:#b91c1c;border-color:#fecaca;background:#fef2f2;">'
1282
+ '⚠️ Lean Finder is currently unavailable. Please try again in a moment '
1283
+ 'or contact <a href="mailto:mike_lu@sfu.ca">mike_lu@sfu.ca</a>.'
1284
+ '</div>'
1285
+ )
1286
+ return err, query, version, [], [], LEAN_FINDER, LEAN_SEARCH, hide_radio, hide_status, hide_row
1287
+
1288
+ lf = _norm_lean_finder(lf_raw)
1289
 
1290
  if mode == "Normal":
1291
+ return (
1292
+ _render_single(lf, query, version),
1293
+ query, version, lf, [], LEAN_FINDER, LEAN_SEARCH,
1294
+ hide_radio, hide_status, hide_row,
1295
+ )
1296
 
1297
+ # Arena mode
1298
  try:
1299
+ ls_raw = _call_lean_search(query, k)
1300
+ ls = _norm_lean_search(ls_raw)
1301
  except RuntimeError:
1302
+ notice = (
1303
+ '<div class="empty-state" style="border-color:#fde68a;background:#fffbeb;color:#92400e;">'
1304
+ '⚠️ LeanSearch is unavailable right now β€” showing Lean Finder results only.'
1305
+ '</div>'
1306
+ )
1307
+ return (
1308
+ notice + _render_single(lf, query, version),
1309
+ query, version, lf, [], LEAN_FINDER, LEAN_SEARCH,
1310
+ hide_radio, hide_status, hide_row,
1311
+ )
1312
+
1313
+ # Random shuffle of A/B so the user can't tell which is which
1314
+ lf_is_a = random.choice([True, False])
1315
+ if lf_is_a:
1316
+ ret_a, ret_b = lf, ls
1317
+ a_src, b_src = LEAN_FINDER, LEAN_SEARCH
1318
  else:
1319
+ ret_a, ret_b = ls, lf
1320
+ a_src, b_src = LEAN_SEARCH, LEAN_FINDER
1321
+
1322
+ return (
1323
+ _render_arena(ret_a, ret_b, a_src, b_src, query, version),
1324
+ query, version, ret_a, ret_b, a_src, b_src,
1325
+ gr.update(visible=True, value=None),
1326
+ gr.update(visible=False, value=""),
1327
+ gr.update(visible=True),
1328
  )
 
 
 
 
1329
 
1330
  run_btn.click(
1331
  retrieve,
1332
+ inputs=[query_box, topk_slider, version_sel, mode_sel],
1333
+ outputs=[results_html, st_query, st_version, st_ret_a, st_ret_b,
1334
+ st_a_source, st_b_source,
1335
+ vote_radio, vote_status, vote_row],
1336
  )
1337
 
1338
+ def _on_mode_change(mode):
1339
  if mode == "Arena":
1340
+ desc = (
1341
+ "_**Arena mode** β€” Lean Finder vs. another model, shown in random order as Retriever A / B. "
1342
+ "Vote for the better set or πŸ‘/πŸ‘Ž individual results. "
1343
+ "Votes help improve Lean Finder β€” we do not collect IP addresses or any personally identifiable information._"
1344
+ )
1345
  else:
1346
+ desc = (
1347
+ "_Normal mode β€” Lean Finder only. Quick results, no voting._"
1348
+ )
1349
  return (
1350
+ '<div class="empty-state">Type a query above and hit <b>Search</b> '
1351
+ 'to see Lean statements from Mathlib.</div>',
1352
  gr.update(visible=False, value=None),
1353
  gr.update(visible=False),
1354
  gr.update(visible=False, value=""),
1355
+ desc,
1356
  )
1357
 
1358
  mode_sel.change(
1359
+ _on_mode_change,
1360
  inputs=mode_sel,
1361
+ outputs=[results_html, vote_radio, vote_row, vote_status, mode_description],
1362
  )
1363
 
1364
  submit_btn.click(
1365
  _save_vote,
1366
+ inputs=[vote_radio, st_query, st_version, st_ret_a, st_ret_b,
1367
+ st_a_source, st_b_source],
1368
+ outputs=vote_status,
1369
  )
1370
+
1371
+ def handle_individual_vote(vote_data: str, query: str, version: str,
1372
+ ret_a: List[Dict[str, Any]], ret_b: List[Dict[str, Any]],
1373
+ a_source: str, b_source: str) -> str:
1374
  if not vote_data:
1375
  return ""
1376
  try:
1377
  data = json.loads(vote_data)
1378
+ # The on-card "system" attribute holds the real source name (Lean Finder / LeanSearch)
1379
+ # because we already pass it through when rendering arena cards.
1380
+ system = data.get("system", "")
1381
  payload = {
1382
+ "retriever_a": ret_a,
1383
+ "retriever_b": ret_b,
1384
+ "a_source": a_source,
1385
+ "b_source": b_source,
1386
+ "individual_vote": data,
1387
  }
 
1388
  _save_individual_vote(
1389
  data["formal_statement"],
1390
  data["vote_decision"],
1391
  data["query"],
1392
+ system,
1393
  data["rank"],
1394
+ version,
1395
+ payload,
1396
  )
1397
+ return f"βœ… **{data['vote_decision']}** recorded for rank {data['rank']}. Thanks!"
1398
  except (json.JSONDecodeError, KeyError) as e:
1399
+ return f"Error saving vote: {e}"
1400
  except Exception as e:
1401
+ return f"ERROR: {e}"
1402
+
1403
  vote_trigger.change(
1404
  handle_individual_vote,
1405
+ inputs=[vote_trigger, st_query, st_version, st_ret_a, st_ret_b,
1406
+ st_a_source, st_b_source],
1407
+ outputs=vote_feedback,
1408
  )
1409
 
1410
+
1411
  if __name__ == "__main__":
1412
+ demo.launch(allowed_paths=["lean_finder_logo.png"])
requirements.txt CHANGED
@@ -1,3 +1,4 @@
1
  huggingface_hub>=0.26.0,<1
2
  gspread
3
- google-auth
 
 
1
  huggingface_hub>=0.26.0,<1
2
  gspread
3
+ google-auth
4
+ requests