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/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")