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/topics.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Topic modelling over the theme-relevant papers.
|
|
2
|
+
|
|
3
|
+
Two backends:
|
|
4
|
+
|
|
5
|
+
* ``llm`` (default) — asks the model to derive a small topic taxonomy from the
|
|
6
|
+
selected papers, then assigns each paper to a topic. No heavy ML dependencies,
|
|
7
|
+
works anywhere the classifier works, and produces human-readable topic names.
|
|
8
|
+
* ``bertopic`` (optional) — classic embedding + clustering via the ``bertopic``
|
|
9
|
+
package, if it is installed. Falls back to ``llm`` with a warning otherwise.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
from .llm import LLMClient
|
|
20
|
+
from .models import Paper, Topic
|
|
21
|
+
|
|
22
|
+
_ABSTRACT_CHARS = 700
|
|
23
|
+
_ASSIGN_BATCH = 25
|
|
24
|
+
_MAX_TOPICS_PER_PAPER = 2
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _theme_block(theme: str, theme_definition: str = "") -> str:
|
|
28
|
+
block = f'Theme: "{theme}"'
|
|
29
|
+
if theme_definition and theme_definition.strip():
|
|
30
|
+
block += f"\nScope of the theme (what counts / doesn't): {theme_definition.strip()}"
|
|
31
|
+
return block
|
|
32
|
+
|
|
33
|
+
_DISCOVER_SYSTEM = (
|
|
34
|
+
"You are a research analyst building a topic taxonomy. Given a set of papers that "
|
|
35
|
+
"all relate to one broad theme, identify the most salient, distinct sub-topics. "
|
|
36
|
+
"Topics must be specific enough to be useful and broad enough to group several "
|
|
37
|
+
"papers. Avoid overlap; prefer descriptive, conference-track-style names."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_DISCOVER_SCHEMA = {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"properties": {
|
|
43
|
+
"topics": {
|
|
44
|
+
"type": "array",
|
|
45
|
+
"items": {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"properties": {
|
|
48
|
+
"name": {"type": "string"},
|
|
49
|
+
"description": {"type": "string"},
|
|
50
|
+
},
|
|
51
|
+
"required": ["name", "description"],
|
|
52
|
+
"additionalProperties": False,
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"required": ["topics"],
|
|
57
|
+
"additionalProperties": False,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_ASSIGN_SYSTEM = (
|
|
61
|
+
"You assign each paper to the topic(s) it fits from a fixed list. Pick the single "
|
|
62
|
+
"best-fitting topic (the primary) by the paper's core contribution. Add a SECOND "
|
|
63
|
+
"topic only if the paper substantially contributes to it as well — most papers get "
|
|
64
|
+
"one topic. Return `topic_ids` (one or two topic ids), best-fitting first."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_ASSIGN_SCHEMA = {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"properties": {
|
|
70
|
+
"assignments": {
|
|
71
|
+
"type": "array",
|
|
72
|
+
"items": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"properties": {
|
|
75
|
+
"index": {"type": "integer"},
|
|
76
|
+
"topic_ids": {"type": "array", "items": {"type": "integer"}},
|
|
77
|
+
},
|
|
78
|
+
"required": ["index", "topic_ids"],
|
|
79
|
+
"additionalProperties": False,
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"required": ["assignments"],
|
|
84
|
+
"additionalProperties": False,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _short(paper: Paper) -> str:
|
|
89
|
+
abstract = (paper.abstract or "").strip()
|
|
90
|
+
if len(abstract) > _ABSTRACT_CHARS:
|
|
91
|
+
abstract = abstract[:_ABSTRACT_CHARS] + "…"
|
|
92
|
+
return f"{paper.title}. {abstract}".strip()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _discover_topics(
|
|
96
|
+
client: LLMClient, theme: str, papers: list[Paper], n_topics: int, theme_definition: str = ""
|
|
97
|
+
) -> list[Topic]:
|
|
98
|
+
lines = [
|
|
99
|
+
_theme_block(theme, theme_definition),
|
|
100
|
+
f"Derive between {max(2, n_topics - 2)} and {n_topics} distinct sub-topics that "
|
|
101
|
+
"best organise the following papers. Return a name and a one-sentence "
|
|
102
|
+
"description for each.",
|
|
103
|
+
"",
|
|
104
|
+
]
|
|
105
|
+
for p in papers:
|
|
106
|
+
lines.append(f"- {p.title}")
|
|
107
|
+
data = client.structured(
|
|
108
|
+
_DISCOVER_SYSTEM, "\n".join(lines), _DISCOVER_SCHEMA, max_tokens=2000, effort="medium"
|
|
109
|
+
)
|
|
110
|
+
topics = []
|
|
111
|
+
for i, t in enumerate(data.get("topics", [])[:n_topics]):
|
|
112
|
+
topics.append(Topic(topic_id=i, name=t["name"], description=t.get("description", "")))
|
|
113
|
+
return topics
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _assign_topics(
|
|
117
|
+
client: LLMClient,
|
|
118
|
+
papers: list[Paper],
|
|
119
|
+
topics: list[Topic],
|
|
120
|
+
progress: Optional[Callable[[int, int], None]] = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
topic_menu = "\n".join(f"[{t.topic_id}] {t.name}: {t.description}" for t in topics)
|
|
123
|
+
total = len(papers)
|
|
124
|
+
done = 0
|
|
125
|
+
if progress:
|
|
126
|
+
progress(0, total)
|
|
127
|
+
for start in range(0, total, _ASSIGN_BATCH):
|
|
128
|
+
batch = papers[start : start + _ASSIGN_BATCH]
|
|
129
|
+
lines = ["Topics:", topic_menu, "", "Papers:"]
|
|
130
|
+
for i, p in enumerate(batch):
|
|
131
|
+
lines.append(f"[{start + i}] {_short(p)}")
|
|
132
|
+
data = client.structured(
|
|
133
|
+
_ASSIGN_SYSTEM, "\n".join(lines), _ASSIGN_SCHEMA, max_tokens=3000, effort="low"
|
|
134
|
+
)
|
|
135
|
+
by_index = {a["index"]: a.get("topic_ids", []) for a in data.get("assignments", [])}
|
|
136
|
+
valid_ids = {t.topic_id for t in topics}
|
|
137
|
+
for i, paper in enumerate(batch):
|
|
138
|
+
ids: list[int] = []
|
|
139
|
+
for tid in by_index.get(start + i) or []:
|
|
140
|
+
if tid in valid_ids and tid not in ids:
|
|
141
|
+
ids.append(tid)
|
|
142
|
+
if len(ids) >= _MAX_TOPICS_PER_PAPER:
|
|
143
|
+
break
|
|
144
|
+
paper.topic_ids = ids or [topics[0].topic_id]
|
|
145
|
+
done += len(batch)
|
|
146
|
+
if progress:
|
|
147
|
+
progress(min(done, total), total)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def model_topics_llm(
|
|
151
|
+
client: LLMClient,
|
|
152
|
+
theme: str,
|
|
153
|
+
papers: list[Paper],
|
|
154
|
+
n_topics: int = 8,
|
|
155
|
+
progress: Optional[Callable[[int, int], None]] = None,
|
|
156
|
+
theme_definition: str = "",
|
|
157
|
+
) -> list[Topic]:
|
|
158
|
+
if not papers:
|
|
159
|
+
return []
|
|
160
|
+
topics = _discover_topics(client, theme, papers, n_topics, theme_definition)
|
|
161
|
+
if not topics:
|
|
162
|
+
topics = [Topic(topic_id=0, name=theme, description="All selected papers.")]
|
|
163
|
+
_assign_topics(client, papers, topics, progress)
|
|
164
|
+
by_id = {t.topic_id: t for t in topics}
|
|
165
|
+
for p in papers:
|
|
166
|
+
for tid in p.topic_ids:
|
|
167
|
+
if tid in by_id:
|
|
168
|
+
by_id[tid].paper_ids.append(p.paper_id)
|
|
169
|
+
# Drop empty topics and renumber so the UI is tidy.
|
|
170
|
+
non_empty = [t for t in topics if t.count > 0]
|
|
171
|
+
non_empty.sort(key=lambda t: t.count, reverse=True)
|
|
172
|
+
remap = {t.topic_id: i for i, t in enumerate(non_empty)}
|
|
173
|
+
for p in papers:
|
|
174
|
+
p.topic_ids = [remap[t] for t in p.topic_ids if t in remap]
|
|
175
|
+
for i, t in enumerate(non_empty):
|
|
176
|
+
t.topic_id = i
|
|
177
|
+
return non_empty
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def model_topics_bertopic(
|
|
181
|
+
theme: str, papers: list[Paper], n_topics: int = 8, **_: object
|
|
182
|
+
) -> list[Topic]:
|
|
183
|
+
"""Optional BERTopic backend. Requires the ``bertopic`` extra to be installed."""
|
|
184
|
+
try:
|
|
185
|
+
from bertopic import BERTopic # type: ignore
|
|
186
|
+
except ImportError as e: # pragma: no cover - optional dependency
|
|
187
|
+
raise RuntimeError(
|
|
188
|
+
"BERTopic backend selected but the 'bertopic' package is not installed. "
|
|
189
|
+
"Install it with `pip install bertopic`, or use the LLM backend."
|
|
190
|
+
) from e
|
|
191
|
+
|
|
192
|
+
docs = [_short(p) for p in papers]
|
|
193
|
+
model = BERTopic(nr_topics=n_topics, calculate_probabilities=False)
|
|
194
|
+
labels, _probs = model.fit_transform(docs)
|
|
195
|
+
info = {row["Topic"]: row["Name"] for _, row in model.get_topic_info().iterrows()}
|
|
196
|
+
|
|
197
|
+
topics: dict[int, Topic] = {}
|
|
198
|
+
next_id = 0
|
|
199
|
+
id_map: dict[int, int] = {}
|
|
200
|
+
for paper, label in zip(papers, labels):
|
|
201
|
+
if label not in id_map:
|
|
202
|
+
id_map[label] = next_id
|
|
203
|
+
name = info.get(label, f"Topic {label}")
|
|
204
|
+
topics[next_id] = Topic(topic_id=next_id, name=str(name), description="")
|
|
205
|
+
next_id += 1
|
|
206
|
+
tid = id_map[label]
|
|
207
|
+
paper.topic_ids = [tid]
|
|
208
|
+
topics[tid].paper_ids.append(paper.paper_id)
|
|
209
|
+
ordered = sorted(topics.values(), key=lambda t: t.count, reverse=True)
|
|
210
|
+
remap = {t.topic_id: i for i, t in enumerate(ordered)}
|
|
211
|
+
for p in papers:
|
|
212
|
+
p.topic_ids = [remap[t] for t in p.topic_ids if t in remap]
|
|
213
|
+
for i, t in enumerate(ordered):
|
|
214
|
+
t.topic_id = i
|
|
215
|
+
return ordered
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def model_topics(
|
|
219
|
+
backend: str,
|
|
220
|
+
client: Optional[LLMClient],
|
|
221
|
+
theme: str,
|
|
222
|
+
papers: list[Paper],
|
|
223
|
+
n_topics: int = 8,
|
|
224
|
+
progress: Optional[Callable[[int, int], None]] = None,
|
|
225
|
+
theme_definition: str = "",
|
|
226
|
+
) -> list[Topic]:
|
|
227
|
+
if backend == "bertopic":
|
|
228
|
+
return model_topics_bertopic(theme, papers, n_topics)
|
|
229
|
+
assert client is not None
|
|
230
|
+
return model_topics_llm(client, theme, papers, n_topics, progress, theme_definition)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# Per-topic synthesis: description + common findings
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
_SUMMARY_MAX_PAPERS = 30 # cap papers per prompt to bound tokens
|
|
237
|
+
_SUMMARY_ABSTRACT_CHARS = 600
|
|
238
|
+
|
|
239
|
+
_SUMMARY_SYSTEM = (
|
|
240
|
+
"You are a research analyst synthesising a cluster of related papers. Write a "
|
|
241
|
+
"short description of what the cluster is collectively about, then extract the "
|
|
242
|
+
"MAIN FINDINGS that are COMMON across the papers — shared methods, recurring "
|
|
243
|
+
"results, consensus positions, common problems or approaches. Focus on what "
|
|
244
|
+
"recurs across multiple papers, NOT points specific to a single paper. Each "
|
|
245
|
+
"finding must be a single, self-contained sentence."
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
_SUMMARY_SCHEMA = {
|
|
249
|
+
"type": "object",
|
|
250
|
+
"properties": {
|
|
251
|
+
"description": {"type": "string"},
|
|
252
|
+
"findings": {"type": "array", "items": {"type": "string"}},
|
|
253
|
+
},
|
|
254
|
+
"required": ["description", "findings"],
|
|
255
|
+
"additionalProperties": False,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _summary_prompt(theme: str, topic: Topic, papers: list[Paper], theme_definition: str = "") -> str:
|
|
260
|
+
lines = [
|
|
261
|
+
_theme_block(theme, theme_definition),
|
|
262
|
+
f'Topic: "{topic.name}"',
|
|
263
|
+
"",
|
|
264
|
+
"Based on the papers below, return:",
|
|
265
|
+
"1. `description`: 1–2 sentences on what this group of papers is collectively about.",
|
|
266
|
+
"2. `findings`: 5 to 10 bullet points capturing the MAIN findings COMMON across "
|
|
267
|
+
"these papers (shared methods, recurring results, consensus, common "
|
|
268
|
+
"challenges). Prefer cross-cutting themes over any single paper's specifics.",
|
|
269
|
+
"",
|
|
270
|
+
"Papers:",
|
|
271
|
+
]
|
|
272
|
+
for p in papers:
|
|
273
|
+
abstract = (p.abstract or "").strip()
|
|
274
|
+
if len(abstract) > _SUMMARY_ABSTRACT_CHARS:
|
|
275
|
+
abstract = abstract[:_SUMMARY_ABSTRACT_CHARS] + "…"
|
|
276
|
+
lines.append(f"- {p.title}. {abstract}".rstrip())
|
|
277
|
+
if len(topic.paper_ids) > _SUMMARY_MAX_PAPERS:
|
|
278
|
+
lines.append(
|
|
279
|
+
f"\n(Showing {_SUMMARY_MAX_PAPERS} of {len(topic.paper_ids)} papers; "
|
|
280
|
+
"synthesise the common themes.)"
|
|
281
|
+
)
|
|
282
|
+
return "\n".join(lines)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _topic_signature(topic: Topic) -> str:
|
|
286
|
+
raw = topic.name + "|" + "|".join(sorted(topic.paper_ids))
|
|
287
|
+
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _summary_cache_path(cache_dir: str, cache_sig: str, theme: str, theme_definition: str) -> str:
|
|
291
|
+
digest = hashlib.sha1(
|
|
292
|
+
f"{cache_sig}|{theme}|{theme_definition}|topicsum".encode("utf-8")
|
|
293
|
+
).hexdigest()[:16]
|
|
294
|
+
return os.path.join(cache_dir, f"topicsum_{digest}.json")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def summarize_topics(
|
|
298
|
+
client: LLMClient,
|
|
299
|
+
theme: str,
|
|
300
|
+
topics: list[Topic],
|
|
301
|
+
papers: list[Paper],
|
|
302
|
+
progress: Optional[Callable[[int, int], None]] = None,
|
|
303
|
+
cache_dir: Optional[str] = None,
|
|
304
|
+
cache_sig: str = "",
|
|
305
|
+
force_refresh: bool = False,
|
|
306
|
+
cancel: Optional[Callable[[], None]] = None,
|
|
307
|
+
theme_definition: str = "",
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Fill each topic's ``description`` and ``findings`` in place.
|
|
310
|
+
|
|
311
|
+
Cached on disk keyed by provider+model, theme, and the topic's exact paper
|
|
312
|
+
membership — so re-running the same analysis reuses summaries, while a topic
|
|
313
|
+
whose papers changed is re-summarised.
|
|
314
|
+
"""
|
|
315
|
+
by_id = {p.paper_id: p for p in papers}
|
|
316
|
+
|
|
317
|
+
cache: dict[str, dict] = {}
|
|
318
|
+
cache_path = None
|
|
319
|
+
if cache_dir:
|
|
320
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
321
|
+
cache_path = _summary_cache_path(cache_dir, cache_sig, theme, theme_definition)
|
|
322
|
+
if not force_refresh and os.path.exists(cache_path):
|
|
323
|
+
try:
|
|
324
|
+
with open(cache_path, "r", encoding="utf-8") as fh:
|
|
325
|
+
cache = json.load(fh)
|
|
326
|
+
except (OSError, json.JSONDecodeError):
|
|
327
|
+
cache = {}
|
|
328
|
+
|
|
329
|
+
total = len(topics)
|
|
330
|
+
if progress:
|
|
331
|
+
progress(0, total)
|
|
332
|
+
|
|
333
|
+
for i, topic in enumerate(topics):
|
|
334
|
+
if cancel:
|
|
335
|
+
cancel()
|
|
336
|
+
sig = _topic_signature(topic)
|
|
337
|
+
hit = cache.get(sig) if not force_refresh else None
|
|
338
|
+
if hit:
|
|
339
|
+
topic.description = hit.get("description", topic.description)
|
|
340
|
+
topic.findings = list(hit.get("findings", []))
|
|
341
|
+
else:
|
|
342
|
+
batch = [by_id[pid] for pid in topic.paper_ids if pid in by_id]
|
|
343
|
+
batch = batch[:_SUMMARY_MAX_PAPERS]
|
|
344
|
+
try:
|
|
345
|
+
data = client.structured(
|
|
346
|
+
_SUMMARY_SYSTEM,
|
|
347
|
+
_summary_prompt(theme, topic, batch, theme_definition),
|
|
348
|
+
_SUMMARY_SCHEMA,
|
|
349
|
+
max_tokens=1500,
|
|
350
|
+
effort="medium",
|
|
351
|
+
)
|
|
352
|
+
topic.description = data.get("description", topic.description) or topic.description
|
|
353
|
+
topic.findings = [str(f) for f in data.get("findings", [])][:10]
|
|
354
|
+
cache[sig] = {"description": topic.description, "findings": topic.findings}
|
|
355
|
+
if cache_path:
|
|
356
|
+
try:
|
|
357
|
+
with open(cache_path, "w", encoding="utf-8") as fh:
|
|
358
|
+
json.dump(cache, fh)
|
|
359
|
+
except OSError:
|
|
360
|
+
pass
|
|
361
|
+
except Exception:
|
|
362
|
+
# A failed summary shouldn't sink the whole run; keep the topic as-is.
|
|
363
|
+
pass
|
|
364
|
+
if progress:
|
|
365
|
+
progress(i + 1, total)
|
conflens/view.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""UI-agnostic view logic shared by the NiceGUI and Gradio front-ends.
|
|
2
|
+
|
|
3
|
+
Both GUIs render the same analysis, so the pure "how to filter / sort / highlight
|
|
4
|
+
/ serialise" logic lives here and is imported by each front-end. Keep this module
|
|
5
|
+
free of any GUI framework (no ``nicegui``, no ``gradio``) so both can use it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import html
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from .models import AnalysisResult, Paper, Topic
|
|
19
|
+
|
|
20
|
+
# Shared topic palette (used for chart bars / topic dots in both GUIs).
|
|
21
|
+
TOPIC_COLORS = [
|
|
22
|
+
"#1f4e79", "#2b6cb0", "#3182ce", "#0b7285", "#2f855a",
|
|
23
|
+
"#975a16", "#9b2c2c", "#6b46c1", "#b83280", "#4a5568",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# --------------------------------------------------------------------------- #
|
|
28
|
+
# Filtering / sorting / highlighting
|
|
29
|
+
# --------------------------------------------------------------------------- #
|
|
30
|
+
def keywords(query: str) -> list[str]:
|
|
31
|
+
"""Split a search query into keywords (comma-separated; may contain spaces)."""
|
|
32
|
+
return [k.strip().lower() for k in (query or "").split(",") if k.strip()]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def matches(paper: Paper, kws: list[str]) -> bool:
|
|
36
|
+
"""True if the paper's title/abstract contains every keyword (AND)."""
|
|
37
|
+
if not kws:
|
|
38
|
+
return True
|
|
39
|
+
text = f"{paper.title}\n{paper.abstract or ''}".lower()
|
|
40
|
+
return all(k in text for k in kws)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def highlight(text: str, kws: list[str]) -> str:
|
|
44
|
+
"""HTML-escape ``text`` and wrap keyword occurrences in ``<mark>``."""
|
|
45
|
+
esc = html.escape(text or "")
|
|
46
|
+
if not kws:
|
|
47
|
+
return esc
|
|
48
|
+
pattern = re.compile("|".join(re.escape(k) for k in kws), re.IGNORECASE)
|
|
49
|
+
return pattern.sub(lambda m: f"<mark>{m.group(0)}</mark>", esc)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def sort_papers(papers: list[Paper], sort: str) -> list[Paper]:
|
|
53
|
+
"""Sort by confidence (desc), title (asc) or year (desc, missing last)."""
|
|
54
|
+
if sort == "title":
|
|
55
|
+
return sorted(papers, key=lambda p: (p.title or "").casefold())
|
|
56
|
+
if sort == "year":
|
|
57
|
+
return sorted(papers, key=lambda p: (p.year or 0), reverse=True)
|
|
58
|
+
return sorted(papers, key=lambda p: (p.confidence or 0), reverse=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def author_choices(result: AnalysisResult) -> list[str]:
|
|
62
|
+
"""Sorted unique author names across the relevant papers."""
|
|
63
|
+
return sorted(
|
|
64
|
+
{a for p in result.relevant_papers for a in p.authors if a},
|
|
65
|
+
key=str.casefold,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def also_in(paper: Paper, current_topic_id: Optional[int], topic_name: dict) -> list[str]:
|
|
70
|
+
"""Names of the paper's other topics (excluding ``current_topic_id``)."""
|
|
71
|
+
return [
|
|
72
|
+
topic_name[tid]
|
|
73
|
+
for tid in paper.topic_ids
|
|
74
|
+
if tid != current_topic_id and tid in topic_name
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def dup_title(paper: Paper, all_by_id: dict) -> Optional[str]:
|
|
79
|
+
"""Title of the representative paper this one is a near-duplicate of, if any."""
|
|
80
|
+
if paper.duplicate_of and paper.duplicate_of in all_by_id:
|
|
81
|
+
return all_by_id[paper.duplicate_of].title
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------- #
|
|
86
|
+
# Computed view (drives the chart + the grouped / global paper lists)
|
|
87
|
+
# --------------------------------------------------------------------------- #
|
|
88
|
+
@dataclass
|
|
89
|
+
class TopicView:
|
|
90
|
+
topic: Topic
|
|
91
|
+
papers: list[Paper]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class ViewData:
|
|
96
|
+
names: list[str] = field(default_factory=list) # all topic names (chart order)
|
|
97
|
+
counts: list[int] = field(default_factory=list) # filtered paper count per topic
|
|
98
|
+
grouped: list[TopicView] = field(default_factory=list) # topics with >=1 match
|
|
99
|
+
flat: list[Paper] = field(default_factory=list) # unique matches (global mode)
|
|
100
|
+
topic_name: dict = field(default_factory=dict)
|
|
101
|
+
all_by_id: dict = field(default_factory=dict)
|
|
102
|
+
total_relevant: int = 0
|
|
103
|
+
total_topics: int = 0
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def shown_grouped_papers(self) -> int:
|
|
107
|
+
return sum(len(tv.papers) for tv in self.grouped)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def compute_view(
|
|
111
|
+
result: AnalysisResult,
|
|
112
|
+
*,
|
|
113
|
+
min_conf: float = 0.0,
|
|
114
|
+
query: str = "",
|
|
115
|
+
author: str = "",
|
|
116
|
+
sort: str = "confidence",
|
|
117
|
+
) -> ViewData:
|
|
118
|
+
"""Apply the live filters and return everything both GUIs need to render."""
|
|
119
|
+
kws = keywords(query)
|
|
120
|
+
by_id = {p.paper_id: p for p in result.relevant_papers}
|
|
121
|
+
all_by_id = {p.paper_id: p for p in result.papers}
|
|
122
|
+
topic_name = {t.topic_id: t.name for t in result.topics}
|
|
123
|
+
|
|
124
|
+
def passes(p: Paper) -> bool:
|
|
125
|
+
if (p.confidence or 0) < min_conf:
|
|
126
|
+
return False
|
|
127
|
+
if author and author not in p.authors:
|
|
128
|
+
return False
|
|
129
|
+
return matches(p, kws)
|
|
130
|
+
|
|
131
|
+
per_topic = {
|
|
132
|
+
t.topic_id: sort_papers(
|
|
133
|
+
[by_id[pid] for pid in t.paper_ids if pid in by_id and passes(by_id[pid])],
|
|
134
|
+
sort,
|
|
135
|
+
)
|
|
136
|
+
for t in result.topics
|
|
137
|
+
}
|
|
138
|
+
names = [t.name for t in result.topics]
|
|
139
|
+
counts = [len(per_topic[t.topic_id]) for t in result.topics]
|
|
140
|
+
grouped = [TopicView(t, per_topic[t.topic_id]) for t in result.topics if per_topic[t.topic_id]]
|
|
141
|
+
|
|
142
|
+
seen: dict = {}
|
|
143
|
+
for t in result.topics:
|
|
144
|
+
for p in per_topic[t.topic_id]:
|
|
145
|
+
seen.setdefault(p.paper_id, p)
|
|
146
|
+
flat = sort_papers(list(seen.values()), sort)
|
|
147
|
+
|
|
148
|
+
return ViewData(
|
|
149
|
+
names=names,
|
|
150
|
+
counts=counts,
|
|
151
|
+
grouped=grouped,
|
|
152
|
+
flat=flat,
|
|
153
|
+
topic_name=topic_name,
|
|
154
|
+
all_by_id=all_by_id,
|
|
155
|
+
total_relevant=len(result.relevant_papers),
|
|
156
|
+
total_topics=len(result.topics),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --------------------------------------------------------------------------- #
|
|
161
|
+
# Exports (bytes, so any GUI can offer them as downloads)
|
|
162
|
+
# --------------------------------------------------------------------------- #
|
|
163
|
+
def json_bytes(result: AnalysisResult) -> bytes:
|
|
164
|
+
return json.dumps(result.to_dict(), indent=2, ensure_ascii=False).encode("utf-8")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def csv_bytes(result: AnalysisResult) -> bytes:
|
|
168
|
+
topics = {t.topic_id: t.name for t in result.topics}
|
|
169
|
+
buf = io.StringIO()
|
|
170
|
+
writer = csv.writer(buf)
|
|
171
|
+
writer.writerow(
|
|
172
|
+
["paper_id", "title", "topics", "confidence", "authors",
|
|
173
|
+
"duplicate_of", "pdf_url", "url"]
|
|
174
|
+
)
|
|
175
|
+
for p in result.relevant_papers:
|
|
176
|
+
writer.writerow(
|
|
177
|
+
[
|
|
178
|
+
p.paper_id,
|
|
179
|
+
p.title,
|
|
180
|
+
"; ".join(topics.get(tid, "") for tid in p.topic_ids),
|
|
181
|
+
f"{p.confidence:.2f}" if p.confidence is not None else "",
|
|
182
|
+
"; ".join(p.authors),
|
|
183
|
+
p.duplicate_of or "",
|
|
184
|
+
p.pdf_url,
|
|
185
|
+
p.url,
|
|
186
|
+
]
|
|
187
|
+
)
|
|
188
|
+
return buf.getvalue().encode("utf-8")
|