conflens 0.1.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
conflens/app.py ADDED
@@ -0,0 +1,788 @@
1
+ """NiceGUI front-end for the conference paper analyzer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import html
7
+ import json
8
+ import os
9
+ import time
10
+ from typing import Optional
11
+
12
+ from nicegui import ui
13
+
14
+ from . import view
15
+ from .cache import default_cache_dir
16
+ from .llm import DEFAULT_MODELS, MODEL_SUGGESTIONS, PROVIDERS, env_key_for
17
+ from .models import AnalysisResult
18
+ from .pipeline import AnalysisConfig, Progress, run_analysis
19
+ from .sources import SOURCES, auth_fields
20
+ from .view import TOPIC_COLORS # shared with the Gradio front-end
21
+
22
+ # Sober, professional palette ------------------------------------------------
23
+ PRIMARY = "#1f4e79" # deep navy
24
+ ACCENT = "#2b6cb0"
25
+ INK = "#1a202c"
26
+ MUTED = "#64748b"
27
+ LINE = "#e2e8f0"
28
+
29
+ _CACHE_DIR = default_cache_dir()
30
+
31
+
32
+ class AnalyzerUI:
33
+ def __init__(self) -> None:
34
+ self.progress = Progress()
35
+ self.result: Optional[AnalysisResult] = None
36
+ self.timer: Optional[ui.timer] = None
37
+ self.running = False
38
+
39
+ # widgets populated in build()
40
+ self.run_btn: Optional[ui.button] = None
41
+ self.progress_card: Optional[ui.card] = None
42
+ self.bar: Optional[ui.linear_progress] = None
43
+ self.status_label: Optional[ui.label] = None
44
+ self.log_area: Optional[ui.log] = None
45
+ self.cancel_btn: Optional[ui.button] = None
46
+ self.elapsed_label: Optional[ui.label] = None
47
+ self._t0: float = 0.0
48
+ self.results_container: Optional[ui.column] = None
49
+ self.chart_container: Optional[ui.column] = None
50
+ self.topics_container: Optional[ui.column] = None
51
+ self.filter_status: Optional[ui.label] = None
52
+ # results-view controls (populated when results are rendered)
53
+ self.search: Optional[ui.input] = None
54
+ self.global_toggle: Optional[ui.switch] = None
55
+ self.sort_select: Optional[ui.select] = None
56
+ self.author_select: Optional[ui.select] = None
57
+ self.conf_view: Optional[ui.slider] = None
58
+ self.conf_view_label: Optional[ui.label] = None
59
+ # Source-credential inputs, keyed by env-var name (populated per source).
60
+ self.auth_container: Optional[ui.column] = None
61
+ self.auth_inputs: dict = {}
62
+
63
+ # ------------------------------------------------------------------ #
64
+ # Layout
65
+ # ------------------------------------------------------------------ #
66
+ def build(self) -> None:
67
+ ui.colors(primary=PRIMARY)
68
+ ui.query("body").style(f"background-color:#ffffff; color:{INK};")
69
+ ui.add_head_html(
70
+ "<style>"
71
+ ".ca-card{border:1px solid %s;border-radius:10px;background:#fff;"
72
+ "box-shadow:0 1px 2px rgba(16,24,40,.04);}"
73
+ ".ca-muted{color:%s;}"
74
+ ".ca-badge{background:%s;color:#fff;border-radius:6px;padding:2px 8px;"
75
+ "font-size:.72rem;font-weight:600;text-decoration:none;}"
76
+ "a.ca-title{color:%s;font-weight:600;text-decoration:none;}"
77
+ "a.ca-title:hover{text-decoration:underline;}"
78
+ "mark{background:#fde68a;color:inherit;padding:0 1px;border-radius:2px;}"
79
+ "</style>" % (LINE, MUTED, ACCENT, INK)
80
+ )
81
+
82
+ # Header
83
+ with ui.header().classes("items-center").style(
84
+ f"background:{PRIMARY}; box-shadow:none; padding:14px 28px;"
85
+ ):
86
+ ui.icon("hub").classes("text-white").style("font-size:26px;")
87
+ with ui.column().classes("gap-0"):
88
+ ui.label("Conference Paper Analyzer").classes("text-white text-h6").style(
89
+ "font-weight:700; line-height:1.1;"
90
+ )
91
+ ui.label("Browse · classify by theme · discover topics").classes(
92
+ "text-white"
93
+ ).style("opacity:.8; font-size:.8rem;")
94
+
95
+ with ui.column().classes("w-full items-center").style("padding:24px 12px;"):
96
+ with ui.column().style("width:100%; max-width:1080px; gap:18px;"):
97
+ self._build_config()
98
+ self._build_progress()
99
+ self.results_container = ui.column().style("width:100%; gap:18px;")
100
+
101
+ def _build_config(self) -> None:
102
+ with ui.card().classes("w-full ca-card").style("padding:20px;"):
103
+ ui.label("Configuration").style(
104
+ f"font-weight:700; color:{INK}; font-size:1.05rem;"
105
+ )
106
+ with ui.row().classes("w-full").style("gap:16px; flex-wrap:wrap;"):
107
+ self.source = ui.select(
108
+ {k: v["label"] for k, v in SOURCES.items()},
109
+ value="aclanthology",
110
+ label="Source",
111
+ ).props("outlined dense").style("flex:1 1 150px;")
112
+ self.base_url = ui.input(
113
+ SOURCES["aclanthology"]["base_label"],
114
+ value=SOURCES["aclanthology"]["base"],
115
+ ).props("outlined dense").style("flex:2 1 240px;")
116
+ self.event = ui.input(
117
+ SOURCES["aclanthology"]["target_label"],
118
+ value=SOURCES["aclanthology"]["target"],
119
+ ).props("outlined dense").style("flex:1 1 200px;")
120
+ self.source.on_value_change(lambda e: self._on_source_change(e.value))
121
+ # Source-credential fields — rendered only for sources that need them
122
+ # (e.g. OpenReview); public sources (ACL, EMNLP, IJCAI, …) show nothing.
123
+ self.auth_container = ui.column().classes("w-full").style("gap:8px;")
124
+ self._render_auth_fields("aclanthology")
125
+ with ui.row().classes("w-full").style("gap:16px; flex-wrap:wrap;"):
126
+ self.theme = ui.input("Theme", value="Agentic AI").props(
127
+ "outlined dense"
128
+ ).style("flex:2 1 220px;")
129
+ self.provider = ui.select(
130
+ PROVIDERS, value="litellm", label="LLM provider"
131
+ ).props("outlined dense").style("flex:1 1 150px;")
132
+ self.model = ui.input(
133
+ "Model", value=DEFAULT_MODELS["litellm"]
134
+ ).props("outlined dense").style("flex:1 1 200px;")
135
+ self.backend = ui.select(
136
+ {"llm": "LLM topics", "bertopic": "BERTopic"},
137
+ value="llm",
138
+ label="Topic engine",
139
+ ).props("outlined dense").style("flex:1 1 150px;")
140
+ self.theme_definition = ui.input(
141
+ "Theme definition (optional)",
142
+ placeholder="Clarify what counts as this theme — what to include / exclude",
143
+ ).props("outlined dense").classes("w-full")
144
+ with ui.row().classes("w-full items-center").style("gap:16px; flex-wrap:wrap;"):
145
+ self.llm_base_url = ui.input(
146
+ "LLM endpoint (LiteLLM / OpenAI-compatible)",
147
+ value=os.environ.get("OPENAI_BASE_URL", ""),
148
+ ).props("outlined dense clearable").style("flex:2 1 320px;")
149
+ self.api_key = ui.input("API key (optional — overrides env var)", value="").props(
150
+ "outlined dense type=password clearable"
151
+ ).style("flex:1 1 240px;")
152
+ self.key_hint = ui.label("").classes("ca-muted").style("font-size:.78rem;")
153
+ self.provider.on_value_change(lambda e: self._on_provider_change(e.value))
154
+ self._on_provider_change("litellm")
155
+ with ui.row().classes("w-full items-center").style("gap:24px; flex-wrap:wrap;"):
156
+ with ui.column().style("flex:1 1 220px; gap:2px;"):
157
+ ui.label("Max papers to scan").classes("ca-muted").style("font-size:.8rem;")
158
+ self.max_papers = ui.number(value=150, min=1, max=10000, step=10).props(
159
+ "outlined dense"
160
+ ).classes("w-full")
161
+ with ui.column().style("flex:1 1 220px; gap:2px;"):
162
+ ui.label("Target number of topics").classes("ca-muted").style(
163
+ "font-size:.8rem;"
164
+ )
165
+ self.n_topics = ui.number(value=8, min=2, max=20, step=1).props(
166
+ "outlined dense"
167
+ ).classes("w-full")
168
+ with ui.column().style("flex:1 1 240px; gap:2px;"):
169
+ self.conf_label = ui.label("Min. confidence: 0.50").classes(
170
+ "ca-muted"
171
+ ).style("font-size:.8rem;")
172
+ self.min_conf = ui.slider(min=0, max=1, step=0.05, value=0.5).props(
173
+ "label-always"
174
+ )
175
+ self.min_conf.on_value_change(
176
+ lambda e: self.conf_label.set_text(f"Min. confidence: {e.value:.2f}")
177
+ )
178
+ with ui.row().classes("w-full items-center justify-between").style(
179
+ "margin-top:6px;"
180
+ ):
181
+ self.refresh = ui.checkbox("Refresh from source (ignore cache)", value=False)
182
+ self.refresh.tooltip(
183
+ "Scraped listings/abstracts AND classification results are cached on "
184
+ "disk and reused across runs (re-running the same theme + model is "
185
+ "instant). Tick this to refetch and re-classify from scratch."
186
+ )
187
+ with ui.row().classes("items-center").style("gap:10px;"):
188
+ self.load_upload = (
189
+ ui.upload(on_upload=self._load_run, auto_upload=True)
190
+ .props('accept=.json flat dense label="Load saved run (.json)"')
191
+ .classes("max-w-[220px]")
192
+ )
193
+ self.load_upload.tooltip(
194
+ "Reload a run you saved earlier with the JSON export — no re-analysis."
195
+ )
196
+ self.run_btn = ui.button(
197
+ "Analyze", icon="play_arrow", on_click=self.start
198
+ ).props("unelevated")
199
+
200
+ def _load_run(self, e) -> None:
201
+ """Restore a previously saved run (JSON export) and render it — no re-run."""
202
+ try:
203
+ raw = e.content.read()
204
+ data = json.loads(raw.decode("utf-8"))
205
+ result = AnalysisResult.from_dict(data)
206
+ except Exception as ex: # malformed / wrong file
207
+ ui.notify(f"Could not load run: {ex}", type="negative", multi_line=True)
208
+ return
209
+ finally:
210
+ self.load_upload.reset()
211
+ if not result.relevant_papers and not result.topics:
212
+ ui.notify("That file doesn't look like a saved analysis run.", type="warning")
213
+ return
214
+ self.result = result
215
+ if self.progress_card:
216
+ self.progress_card.style("display:none;")
217
+ ui.notify(
218
+ f"Loaded run: “{result.theme}” · {len(result.relevant_papers)} papers.",
219
+ type="positive",
220
+ )
221
+ self._render_results(result)
222
+
223
+ def _on_source_change(self, source: str) -> None:
224
+ """Prefill the base URL / target and relabel them for the chosen source."""
225
+ cfg = SOURCES.get(source)
226
+ if not cfg:
227
+ return
228
+ self.base_url.set_value(cfg["base"])
229
+ self.base_url.props(f'label="{cfg["base_label"]}"')
230
+ self.event.set_value(cfg["target"])
231
+ self.event.props(f'label="{cfg["target_label"]}"')
232
+ self._render_auth_fields(source)
233
+
234
+ def _render_auth_fields(self, source: str) -> None:
235
+ """(Re)build the credential inputs — only for sources that need them."""
236
+ fields = auth_fields(source)
237
+ self.auth_container.clear()
238
+ self.auth_inputs = {}
239
+ if not fields:
240
+ self.auth_container.style("display:none;")
241
+ return
242
+ self.auth_container.style("display:flex;")
243
+ label = SOURCES.get(source, {}).get("label", source)
244
+ with self.auth_container:
245
+ ui.label(f"{label} — authentication required").style(
246
+ f"font-weight:600; color:{INK}; font-size:.85rem;"
247
+ )
248
+ with ui.row().classes("w-full items-center").style("gap:16px; flex-wrap:wrap;"):
249
+ for f in fields:
250
+ props = "outlined dense clearable"
251
+ if f.get("secret"):
252
+ props += " type=password"
253
+ inp = ui.input(f["label"]).props(props).style("flex:1 1 240px;")
254
+ if f.get("help"):
255
+ inp.tooltip(f["help"])
256
+ self.auth_inputs[f["env"]] = inp
257
+ ui.label(
258
+ "Leave a field blank to use its environment variable if set "
259
+ f"({', '.join(f['env'] for f in fields)})."
260
+ ).classes("ca-muted").style("font-size:.76rem;")
261
+
262
+ def _on_provider_change(self, provider: str) -> None:
263
+ """Update the default model, endpoint relevance and key hint per provider."""
264
+ self.model.set_value(DEFAULT_MODELS.get(provider, ""))
265
+ suggestions = ", ".join(MODEL_SUGGESTIONS.get(provider, []))
266
+ self.model.props(f'placeholder="{suggestions}"')
267
+ needs_endpoint = provider == "litellm"
268
+ self.llm_base_url.props(
269
+ "outlined dense clearable"
270
+ + (" required" if needs_endpoint else "")
271
+ )
272
+ has_env = bool(env_key_for(provider))
273
+ env_var = {
274
+ "anthropic": "ANTHROPIC_API_KEY",
275
+ "openai": "OPENAI_API_KEY",
276
+ "litellm": "LITELLM_API_KEY / OPENAI_API_KEY",
277
+ }.get(provider, "")
278
+ parts = [f"Models e.g.: {suggestions}." if suggestions else ""]
279
+ if has_env:
280
+ parts.append(f"Using {env_var} from the environment.")
281
+ else:
282
+ parts.append(f"No {env_var} found — set it or fill the API key field.")
283
+ if needs_endpoint:
284
+ parts.append("LiteLLM: enter your endpoint above.")
285
+ self.key_hint.set_text(" ".join(p for p in parts if p))
286
+
287
+ def _build_progress(self) -> None:
288
+ self.progress_card = ui.card().classes("w-full ca-card").style(
289
+ "padding:18px; display:none;"
290
+ )
291
+ with self.progress_card:
292
+ with ui.row().classes("w-full items-center justify-between").style("gap:12px;"):
293
+ self.status_label = ui.label("").style(f"font-weight:600; color:{INK};")
294
+ with ui.row().classes("items-center").style("gap:10px;"):
295
+ self.elapsed_label = ui.label("").classes("ca-muted").style(
296
+ "font-size:.8rem; font-variant-numeric:tabular-nums;"
297
+ )
298
+ self.cancel_btn = ui.button(
299
+ "Cancel", icon="stop", on_click=self._cancel
300
+ ).props("outline dense color=negative")
301
+ self.bar = ui.linear_progress(value=0, show_value=False).props("rounded")
302
+ with ui.expansion("Activity log").classes("w-full").style("margin-top:4px;"):
303
+ self.log_area = ui.log(max_lines=200).classes("w-full").style(
304
+ "height:160px; font-family:ui-monospace,monospace; font-size:.78rem;"
305
+ )
306
+
307
+ # ------------------------------------------------------------------ #
308
+ # Run lifecycle
309
+ # ------------------------------------------------------------------ #
310
+ def _validate(self) -> Optional[str]:
311
+ """Return a user-facing error if the form isn't ready to run, else None."""
312
+ if not (self.model.value or "").strip():
313
+ return "Please set a model before running."
314
+ if not (self.event.value or "").strip():
315
+ return "Please set the event / accepted-papers target."
316
+ if self.provider.value == "litellm" and not (self.llm_base_url.value or "").strip():
317
+ return "LiteLLM needs an LLM endpoint — fill the “LLM endpoint” field."
318
+ return None
319
+
320
+ async def start(self) -> None:
321
+ if self.running:
322
+ return
323
+
324
+ # Validate inputs before doing any work.
325
+ error = self._validate()
326
+ if error:
327
+ ui.notify(error, type="warning")
328
+ return
329
+
330
+ self.running = True
331
+ self.progress = Progress()
332
+ self.result = None
333
+ self._logged = 0
334
+ self._t0 = time.monotonic()
335
+ self.run_btn.props("loading")
336
+ self.cancel_btn.props(remove="disable")
337
+ self.results_container.clear()
338
+ self.progress_card.style("display:block;")
339
+ self.bar.set_value(0)
340
+ self.status_label.set_text("Starting…")
341
+ self.elapsed_label.set_text("0:00")
342
+ self.log_area.clear()
343
+
344
+ source_auth = {
345
+ env: (inp.value or "").strip()
346
+ for env, inp in self.auth_inputs.items()
347
+ if (inp.value or "").strip()
348
+ }
349
+ cfg = AnalysisConfig(
350
+ source=self.source.value,
351
+ base_url=self.base_url.value.strip(),
352
+ event=self.event.value.strip(),
353
+ theme=self.theme.value.strip() or "Agentic AI",
354
+ theme_definition=(self.theme_definition.value or "").strip(),
355
+ provider=self.provider.value,
356
+ model=(self.model.value or "").strip(),
357
+ llm_base_url=(self.llm_base_url.value or "").strip(),
358
+ api_key=(self.api_key.value or "").strip(),
359
+ max_papers=int(self.max_papers.value or 150),
360
+ n_topics=int(self.n_topics.value or 8),
361
+ min_confidence=float(self.min_conf.value),
362
+ topic_backend=self.backend.value,
363
+ refresh=bool(self.refresh.value),
364
+ source_auth=source_auth,
365
+ )
366
+
367
+ self.timer = ui.timer(0.25, self._tick)
368
+ try:
369
+ self.result = await asyncio.to_thread(
370
+ run_analysis, cfg, self.progress, _CACHE_DIR
371
+ )
372
+ except Exception as e: # surface any failure to the user
373
+ self.progress.error = str(e)
374
+ finally:
375
+ if self.timer:
376
+ self.timer.cancel()
377
+ self._tick() # final flush
378
+ self.running = False
379
+ self.run_btn.props(remove="loading")
380
+ self.cancel_btn.props("disable")
381
+
382
+ if self.progress.error:
383
+ self.status_label.set_text(f"Error: {self.progress.error}")
384
+ ui.notify(self.progress.error, type="negative", multi_line=True)
385
+ return
386
+ if self.progress.cancelled:
387
+ self.status_label.set_text("Cancelled.")
388
+ ui.notify("Analysis cancelled.", type="info")
389
+ return
390
+ if self.result:
391
+ self._render_results(self.result)
392
+
393
+ def _cancel(self) -> None:
394
+ if self.running:
395
+ self.progress.cancelled = True
396
+ self.status_label.set_text("Cancelling…")
397
+ self.cancel_btn.props("disable")
398
+
399
+ def _tick(self) -> None:
400
+ # flush new log lines
401
+ while self._logged < len(self.progress.log):
402
+ self.log_area.push(self.progress.log[self._logged])
403
+ self._logged += 1
404
+ if self.progress.message:
405
+ self.status_label.set_text(self.progress.message)
406
+ self.bar.set_value(self.progress.fraction)
407
+ if self.running:
408
+ secs = int(time.monotonic() - self._t0)
409
+ self.elapsed_label.set_text(f"{secs // 60}:{secs % 60:02d}")
410
+
411
+ # ------------------------------------------------------------------ #
412
+ # Results rendering
413
+ # ------------------------------------------------------------------ #
414
+ def _render_results(self, result: AnalysisResult) -> None:
415
+ self.results_container.clear()
416
+ with self.results_container:
417
+ if not result.relevant_papers:
418
+ with ui.card().classes("w-full ca-card").style("padding:24px;"):
419
+ ui.label("No matching papers").style(
420
+ f"font-weight:700; color:{INK}; font-size:1.05rem;"
421
+ )
422
+ ui.label(
423
+ f"Scanned {result.scanned} papers but found none about "
424
+ f"“{result.theme}”."
425
+ ).classes("ca-muted")
426
+ return
427
+
428
+ self._render_summary(result)
429
+ self.chart_container = ui.column().classes("w-full").style("gap:0;")
430
+ self._render_controls(result)
431
+ self.topics_container = ui.column().style("width:100%; gap:18px;")
432
+ self._apply_view()
433
+
434
+ def _render_summary(self, result: AnalysisResult) -> None:
435
+ with ui.card().classes("w-full ca-card").style("padding:18px 22px;"):
436
+ with ui.row().classes("w-full items-center justify-between").style(
437
+ "flex-wrap:wrap; gap:12px;"
438
+ ):
439
+ with ui.row().style("gap:36px; flex-wrap:wrap;"):
440
+ self._stat(str(result.scanned), "Papers scanned")
441
+ self._stat(str(len(result.relevant_papers)), f"Relevant to “{result.theme}”")
442
+ self._stat(str(len(result.topics)), "Topics")
443
+ if result.duplicate_groups:
444
+ self._stat(str(result.duplicate_groups), "Near-duplicate groups")
445
+ with ui.row().style("gap:8px;"):
446
+ ui.button(
447
+ "PPTX", icon="slideshow", on_click=self._download_pptx
448
+ ).props("unelevated dense")
449
+ ui.button("JSON", icon="download", on_click=self._download_json).props(
450
+ "outline dense"
451
+ ).tooltip("Save this run — reload it later with “Load saved run”.")
452
+ ui.button("CSV", icon="download", on_click=self._download_csv).props(
453
+ "outline dense"
454
+ )
455
+ ui.button("BibTeX", icon="download", on_click=self._download_bibtex).props(
456
+ "outline dense"
457
+ )
458
+ ui.link(
459
+ "Source: " + result.event_url, result.event_url, new_tab=True
460
+ ).classes("ca-muted").style("font-size:.8rem;")
461
+
462
+ def _stat(self, value: str, label: str) -> None:
463
+ with ui.column().classes("gap-0 items-start"):
464
+ ui.label(value).style(f"font-size:1.8rem; font-weight:700; color:{PRIMARY};")
465
+ ui.label(label).classes("ca-muted").style("font-size:.8rem;")
466
+
467
+ def _render_chart(self, names: list[str], counts: list[int]) -> None:
468
+ with ui.card().classes("w-full ca-card").style("padding:18px 22px;"):
469
+ ui.label("Papers per topic").style(f"font-weight:700; color:{INK};")
470
+ ui.echart(
471
+ {
472
+ "tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
473
+ "grid": {"left": 8, "right": 24, "top": 16, "bottom": 8, "containLabel": True},
474
+ "xAxis": {"type": "value", "minInterval": 1},
475
+ "yAxis": {
476
+ "type": "category",
477
+ "data": list(reversed(names)),
478
+ "axisLabel": {"width": 220, "overflow": "truncate"},
479
+ },
480
+ "series": [
481
+ {
482
+ "type": "bar",
483
+ "data": [
484
+ {"value": c, "itemStyle": {"color": TOPIC_COLORS[i % len(TOPIC_COLORS)]}}
485
+ for i, c in reversed(list(enumerate(counts)))
486
+ ],
487
+ "barMaxWidth": 26,
488
+ "label": {"show": True, "position": "right"},
489
+ }
490
+ ],
491
+ }
492
+ ).style(f"height:{max(160, 46 * len(names))}px; width:100%;")
493
+
494
+ # ------------------------------------------------------------------ #
495
+ # Results-view controls (all filter/sort live, client-side)
496
+ # ------------------------------------------------------------------ #
497
+ def _render_controls(self, result: AnalysisResult) -> None:
498
+ authors = view.author_choices(result)
499
+ with ui.card().classes("w-full ca-card").style("padding:12px 18px;"):
500
+ with ui.row().classes("w-full items-center").style("gap:12px; flex-wrap:wrap;"):
501
+ ui.icon("search").style(f"color:{MUTED};")
502
+ self.search = (
503
+ ui.input(
504
+ placeholder="Filter by keywords in the title or abstract "
505
+ "(comma-separated; each keyword may contain spaces)"
506
+ )
507
+ .props("dense clearable")
508
+ .style("flex:1 1 300px;")
509
+ )
510
+ self.search.on_value_change(lambda _: self._apply_view())
511
+ self.global_toggle = ui.switch("Search all topics").props("dense")
512
+ self.global_toggle.tooltip(
513
+ "Show every matching paper in one ranked list instead of grouping by topic."
514
+ )
515
+ self.global_toggle.on_value_change(lambda _: self._apply_view())
516
+ with ui.row().classes("w-full items-center").style(
517
+ "gap:16px; flex-wrap:wrap; margin-top:8px;"
518
+ ):
519
+ self.sort_select = (
520
+ ui.select(
521
+ {"confidence": "Confidence", "title": "Title", "year": "Year"},
522
+ value="confidence",
523
+ label="Sort by",
524
+ )
525
+ .props("dense outlined")
526
+ .style("flex:1 1 150px; max-width:200px;")
527
+ )
528
+ self.sort_select.on_value_change(lambda _: self._apply_view())
529
+ self.author_select = (
530
+ ui.select(
531
+ authors,
532
+ value=None,
533
+ label="Filter by author",
534
+ with_input=True,
535
+ clearable=True,
536
+ )
537
+ .props("dense outlined")
538
+ .style("flex:1 1 220px; max-width:320px;")
539
+ )
540
+ self.author_select.on_value_change(lambda _: self._apply_view())
541
+ with ui.column().classes("gap-0").style("flex:1 1 220px; min-width:200px;"):
542
+ self.conf_view_label = ui.label("").classes("ca-muted").style(
543
+ "font-size:.8rem;"
544
+ )
545
+ self.conf_view = ui.slider(
546
+ min=0, max=1, step=0.05, value=result.min_confidence
547
+ ).props("label-always")
548
+ self.conf_view.on_value_change(lambda _: self._apply_view())
549
+ self.filter_status = ui.label("").classes("ca-muted").style(
550
+ "font-size:.8rem; white-space:nowrap;"
551
+ )
552
+
553
+ # These delegate to the shared, GUI-agnostic view logic (also used by Gradio).
554
+ @staticmethod
555
+ def _keywords(query: str) -> list[str]:
556
+ return view.keywords(query)
557
+
558
+ @staticmethod
559
+ def _matches(paper, keywords: list[str]) -> bool:
560
+ return view.matches(paper, keywords)
561
+
562
+ @staticmethod
563
+ def _highlight(text: str, keywords: list[str]) -> str:
564
+ return view.highlight(text, keywords)
565
+
566
+ def _sorted(self, papers: list, sort: str) -> list:
567
+ return view.sort_papers(papers, sort)
568
+
569
+ def _apply_view(self) -> None:
570
+ """Recompute the filtered/sorted view and re-render chart + papers live."""
571
+ result = self.result
572
+ if result is None or self.topics_container is None:
573
+ return
574
+ query = self.search.value if self.search else ""
575
+ keywords = view.keywords(query)
576
+ min_conf = float(self.conf_view.value) if self.conf_view else 0.0
577
+ author = (self.author_select.value or "") if self.author_select else ""
578
+ sort = (self.sort_select.value or "confidence") if self.sort_select else "confidence"
579
+ is_global = bool(self.global_toggle.value) if self.global_toggle else False
580
+
581
+ vd = view.compute_view(
582
+ result, min_conf=min_conf, query=query, author=author, sort=sort
583
+ )
584
+
585
+ # Chart reflects the live view.
586
+ if self.chart_container is not None:
587
+ self.chart_container.clear()
588
+ with self.chart_container:
589
+ self._render_chart(vd.names, vd.counts)
590
+
591
+ self.topics_container.clear()
592
+ with self.topics_container:
593
+ if is_global:
594
+ shown_papers, shown_topics = self._render_global(vd, keywords)
595
+ else:
596
+ shown_papers, shown_topics = self._render_grouped(vd, keywords)
597
+
598
+ self._update_status(result, shown_papers, shown_topics, min_conf, is_global)
599
+
600
+ def _update_status(self, result, shown_papers, shown_topics, min_conf, is_global) -> None:
601
+ if self.conf_view_label is not None:
602
+ self.conf_view_label.set_text(f"Show ≥ {min_conf:.2f} confidence")
603
+ if self.filter_status is not None:
604
+ scope = "in one list" if is_global else f"· {shown_topics} of {len(result.topics)} topics"
605
+ self.filter_status.set_text(
606
+ f"{shown_papers} of {len(result.relevant_papers)} papers {scope}"
607
+ )
608
+
609
+ def _render_grouped(self, vd, keywords) -> tuple:
610
+ active = bool(keywords) # auto-open + hide-empty only when keyword filtering
611
+ shown_papers = 0
612
+ shown_topics = 0
613
+ for tv in vd.grouped:
614
+ t = tv.topic
615
+ papers = tv.papers
616
+ shown_papers += len(papers)
617
+ shown_topics += 1
618
+ color = TOPIC_COLORS[t.topic_id % len(TOPIC_COLORS)]
619
+ badge = (
620
+ f"{len(papers)} of {t.count}"
621
+ if len(papers) != t.count
622
+ else f"{t.count} paper{'s' if t.count != 1 else ''}"
623
+ )
624
+ with ui.card().classes("w-full ca-card").style("padding:0; overflow:hidden;"):
625
+ with ui.expansion().classes("w-full").props(
626
+ "default-opened" if active else ""
627
+ ) as exp:
628
+ with exp.add_slot("header"):
629
+ with ui.row().classes("w-full items-center").style("gap:12px;"):
630
+ ui.element("div").style(
631
+ f"width:10px; height:10px; border-radius:50%; background:{color};"
632
+ )
633
+ ui.label(t.name).style(f"font-weight:700; color:{INK};")
634
+ ui.label(badge).classes("ca-badge").style(f"background:{color};")
635
+ with ui.column().classes("w-full").style(
636
+ "padding:4px 18px 14px 18px; gap:10px;"
637
+ ):
638
+ if t.description:
639
+ ui.label(t.description).style(
640
+ f"color:{INK}; font-size:.9rem; line-height:1.5;"
641
+ )
642
+ if t.findings:
643
+ with ui.column().classes("w-full").style(
644
+ f"gap:4px; background:#f8fafc; border:1px solid {LINE}; "
645
+ "border-radius:8px; padding:12px 16px;"
646
+ ):
647
+ ui.label("Main findings across this topic").style(
648
+ f"font-weight:700; color:{PRIMARY}; font-size:.8rem; "
649
+ "text-transform:uppercase; letter-spacing:.04em;"
650
+ )
651
+ ui.markdown(
652
+ "\n".join(f"- {f}" for f in t.findings)
653
+ ).style(f"color:{INK}; font-size:.85rem;")
654
+ ui.label(f"Papers ({len(papers)})").classes("ca-muted").style(
655
+ "font-size:.8rem; font-weight:600; margin-top:4px;"
656
+ )
657
+ for p in papers:
658
+ self._render_paper(
659
+ p, keywords,
660
+ also_in=view.also_in(p, t.topic_id, vd.topic_name),
661
+ dup_title=view.dup_title(p, vd.all_by_id),
662
+ )
663
+ if shown_topics == 0:
664
+ ui.label("No papers match the current filters.").classes("ca-muted").style(
665
+ "padding:8px 2px;"
666
+ )
667
+ return shown_papers, shown_topics
668
+
669
+ def _render_global(self, vd, keywords) -> tuple:
670
+ # Flatten unique papers across topics (a multi-topic paper appears once).
671
+ papers = vd.flat
672
+ with ui.card().classes("w-full ca-card").style("padding:6px 18px 14px 18px;"):
673
+ ui.label(f"All matching papers ({len(papers)})").classes("ca-muted").style(
674
+ "font-size:.8rem; font-weight:600; margin-top:8px;"
675
+ )
676
+ if not papers:
677
+ ui.label("No papers match the current filters.").classes("ca-muted").style(
678
+ "padding:8px 2px;"
679
+ )
680
+ for p in papers:
681
+ self._render_paper(
682
+ p, keywords,
683
+ also_in=[vd.topic_name[tid] for tid in p.topic_ids if tid in vd.topic_name],
684
+ dup_title=view.dup_title(p, vd.all_by_id),
685
+ also_label="Topics: ",
686
+ )
687
+ return len(papers), 1 if papers else 0
688
+
689
+ def _render_paper(
690
+ self, p, keywords=None, also_in=None, dup_title=None, also_label="Also in: "
691
+ ) -> None:
692
+ keywords = keywords or []
693
+ with ui.column().classes("w-full").style(
694
+ f"gap:3px; padding:10px 0; border-top:1px solid {LINE};"
695
+ ):
696
+ with ui.row().classes("w-full items-start justify-between").style("gap:10px;"):
697
+ if keywords:
698
+ title_html = self._highlight(p.title, keywords)
699
+ if p.url:
700
+ ui.html(
701
+ f'<a href="{html.escape(p.url)}" target="_blank" '
702
+ f'rel="noopener" class="ca-title">{title_html}</a>'
703
+ )
704
+ else:
705
+ ui.html(f'<span style="color:{INK};font-weight:600;">{title_html}</span>')
706
+ elif p.url:
707
+ ui.link(p.title, p.url, new_tab=True).classes("ca-title")
708
+ else:
709
+ ui.label(p.title).style(f"color:{INK}; font-weight:600;")
710
+ with ui.row().style("gap:6px; flex-wrap:nowrap;"):
711
+ if dup_title:
712
+ ui.label("near-dup").classes("ca-badge").style(
713
+ "background:#b7791f;"
714
+ ).tooltip(f"Near-duplicate of: {dup_title}")
715
+ if p.confidence is not None:
716
+ ui.label(f"{p.confidence:.0%}").classes("ca-badge").style(
717
+ f"background:{MUTED};"
718
+ ).tooltip("Relevance confidence")
719
+ if p.pdf_url:
720
+ ui.link("PDF", p.pdf_url, new_tab=True).classes("ca-badge")
721
+ if p.authors:
722
+ authors = ", ".join(p.authors[:6]) + ("…" if len(p.authors) > 6 else "")
723
+ ui.label(authors).classes("ca-muted").style("font-size:.8rem;")
724
+ if also_in:
725
+ ui.label(also_label + ", ".join(also_in)).classes("ca-muted").style(
726
+ "font-size:.78rem;"
727
+ )
728
+ if p.abstract:
729
+ with ui.expansion("Abstract").classes("w-full").style("font-size:.85rem;"):
730
+ if keywords:
731
+ ui.html(self._highlight(p.abstract, keywords)).style(
732
+ f"color:{INK}; font-size:.85rem; line-height:1.5;"
733
+ )
734
+ else:
735
+ ui.label(p.abstract).style(
736
+ f"color:{INK}; font-size:.85rem; line-height:1.5;"
737
+ )
738
+ if p.reason:
739
+ ui.label("Why: " + p.reason).classes("ca-muted").style(
740
+ "font-size:.78rem; font-style:italic;"
741
+ )
742
+
743
+ # ------------------------------------------------------------------ #
744
+ # Exports
745
+ # ------------------------------------------------------------------ #
746
+ async def _download_pptx(self) -> None:
747
+ if not self.result:
748
+ return
749
+ from .pptx_export import build_pptx
750
+
751
+ ui.notify("Building slide deck…", type="ongoing")
752
+ try:
753
+ data = await asyncio.to_thread(build_pptx, self.result)
754
+ except Exception as e: # missing dependency or rendering failure
755
+ ui.notify(str(e), type="negative", multi_line=True)
756
+ return
757
+ ui.download.content(
758
+ data,
759
+ "analysis.pptx",
760
+ media_type=(
761
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation"
762
+ ),
763
+ )
764
+
765
+ def _download_json(self) -> None:
766
+ if not self.result:
767
+ return
768
+ ui.download.content(view.json_bytes(self.result), "analysis.json")
769
+
770
+ def _download_bibtex(self) -> None:
771
+ if not self.result:
772
+ return
773
+ from .bibtex import build_bibtex
774
+
775
+ ui.download.content(
776
+ build_bibtex(self.result).encode("utf-8"),
777
+ "papers.bib",
778
+ media_type="application/x-bibtex",
779
+ )
780
+
781
+ def _download_csv(self) -> None:
782
+ if not self.result:
783
+ return
784
+ ui.download.content(view.csv_bytes(self.result), "analysis.csv")
785
+
786
+
787
+ def create_ui() -> None:
788
+ AnalyzerUI().build()