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/__init__.py +8 -0
- conflens/app.py +788 -0
- conflens/bibtex.py +56 -0
- conflens/cache.py +36 -0
- conflens/classifier.py +174 -0
- conflens/cli.py +75 -0
- conflens/dedup.py +100 -0
- conflens/llm.py +336 -0
- conflens/models.py +116 -0
- conflens/pipeline.py +207 -0
- conflens/pptx_export.py +225 -0
- conflens/scraper.py +277 -0
- conflens/sources.py +839 -0
- conflens/topics.py +365 -0
- conflens/view.py +188 -0
- conflens-0.1.14.dist-info/METADATA +349 -0
- conflens-0.1.14.dist-info/RECORD +20 -0
- conflens-0.1.14.dist-info/WHEEL +4 -0
- conflens-0.1.14.dist-info/entry_points.txt +3 -0
- conflens-0.1.14.dist-info/licenses/LICENSE +373 -0
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()
|