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/llm.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Provider-agnostic LLM layer.
|
|
2
|
+
|
|
3
|
+
Three backends share one interface (:meth:`LLMClient.structured`):
|
|
4
|
+
|
|
5
|
+
* **anthropic** — Claude models via the ``anthropic`` SDK with native structured
|
|
6
|
+
outputs (``output_config.format``);
|
|
7
|
+
* **openai** — any OpenAI or OpenAI-compatible endpoint via the ``openai`` SDK; and
|
|
8
|
+
* **litellm** — the ``litellm`` SDK, which proxies 100+ providers and supports a
|
|
9
|
+
custom ``api_base`` (e.g. a self-hosted LiteLLM endpoint).
|
|
10
|
+
|
|
11
|
+
For the OpenAI-compatible backends we request JSON-object responses and embed the
|
|
12
|
+
target schema in the prompt, then parse defensively — this is the most portable
|
|
13
|
+
approach across arbitrary proxied models. Anthropic uses its stricter native
|
|
14
|
+
schema enforcement.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import random
|
|
22
|
+
import re
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any, Callable, Optional
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Provider / model catalogue (suggestions shown in the UI; any string works)
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
PROVIDERS = ["anthropic", "openai", "litellm"]
|
|
30
|
+
|
|
31
|
+
DEFAULT_MODELS = {
|
|
32
|
+
"anthropic": "claude-opus-4-8",
|
|
33
|
+
"openai": "gpt-5.4",
|
|
34
|
+
"litellm": "gpt-5.4", # user supplies, e.g. "openai/gpt-4o" or a proxy model name
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
MODEL_SUGGESTIONS = {
|
|
38
|
+
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5"],
|
|
39
|
+
"openai": ["gpt-5.4", "gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "o4-mini"],
|
|
40
|
+
"litellm": ["gpt-5.4","gpt-5.5","gpt-5.1"],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Anthropic: the `effort` parameter is rejected (400) on models that don't
|
|
44
|
+
# support it — notably Haiku 4.5. Only send it for models that accept it.
|
|
45
|
+
_EFFORT_MODELS = {"claude-opus-4-8", "claude-sonnet-4-6"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LLMError(RuntimeError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Transient-error retry (rate limits, overload, 5xx, timeouts, connection)
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
_MAX_ATTEMPTS = 4
|
|
56
|
+
_TRANSIENT_STATUS = {408, 409, 425, 429, 500, 502, 503, 504, 529}
|
|
57
|
+
_TRANSIENT_HINTS = (
|
|
58
|
+
"rate limit",
|
|
59
|
+
"overloaded",
|
|
60
|
+
"timeout",
|
|
61
|
+
"timed out",
|
|
62
|
+
"temporarily unavailable",
|
|
63
|
+
"connection",
|
|
64
|
+
"econnreset",
|
|
65
|
+
"too many requests",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_transient(exc: Exception) -> bool:
|
|
70
|
+
"""Heuristically decide whether an LLM/provider error is worth retrying.
|
|
71
|
+
|
|
72
|
+
Works across SDKs without importing them: checks a numeric status code if
|
|
73
|
+
present, otherwise the exception class name / message.
|
|
74
|
+
"""
|
|
75
|
+
status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
|
|
76
|
+
if isinstance(status, int) and status in _TRANSIENT_STATUS:
|
|
77
|
+
return True
|
|
78
|
+
name = type(exc).__name__.lower()
|
|
79
|
+
if any(k in name for k in ("ratelimit", "timeout", "connection", "overloaded", "apistatus")):
|
|
80
|
+
# APIStatusError with a non-transient status is filtered above by the
|
|
81
|
+
# status check returning False only when a status is present; here it
|
|
82
|
+
# has no known status, so treat connection/timeout/ratelimit as transient.
|
|
83
|
+
if "apistatus" in name and not isinstance(status, int):
|
|
84
|
+
return False
|
|
85
|
+
return True
|
|
86
|
+
msg = str(exc).lower()
|
|
87
|
+
return any(h in msg for h in _TRANSIENT_HINTS)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _retry(fn: Callable[[], Any], *, max_attempts: int = _MAX_ATTEMPTS) -> Any:
|
|
91
|
+
"""Call ``fn`` with exponential backoff on transient errors."""
|
|
92
|
+
last: Optional[Exception] = None
|
|
93
|
+
for attempt in range(max_attempts):
|
|
94
|
+
try:
|
|
95
|
+
return fn()
|
|
96
|
+
except Exception as e: # noqa: BLE001 - provider exceptions vary
|
|
97
|
+
last = e
|
|
98
|
+
if attempt == max_attempts - 1 or not _is_transient(e):
|
|
99
|
+
raise
|
|
100
|
+
delay = min(1.5 * (2 ** attempt), 20.0) + random.uniform(0, 0.75)
|
|
101
|
+
time.sleep(delay)
|
|
102
|
+
assert last is not None
|
|
103
|
+
raise last
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Helpers shared by the OpenAI-compatible backends
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
def _schema_hint(schema: dict[str, Any]) -> str:
|
|
110
|
+
return (
|
|
111
|
+
"Respond with ONLY a single JSON object — no prose, no markdown code "
|
|
112
|
+
"fences — that conforms to this JSON Schema:\n"
|
|
113
|
+
+ json.dumps(schema)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _extract_json(text: str) -> Any:
|
|
118
|
+
"""Parse a JSON object from a model response, tolerating fences/prose."""
|
|
119
|
+
if text is None:
|
|
120
|
+
raise LLMError("Empty response from the model.")
|
|
121
|
+
s = text.strip()
|
|
122
|
+
if s.startswith("```"):
|
|
123
|
+
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
|
124
|
+
s = re.sub(r"\s*```$", "", s)
|
|
125
|
+
try:
|
|
126
|
+
return json.loads(s)
|
|
127
|
+
except json.JSONDecodeError:
|
|
128
|
+
i, j = s.find("{"), s.rfind("}")
|
|
129
|
+
if 0 <= i < j:
|
|
130
|
+
try:
|
|
131
|
+
return json.loads(s[i : j + 1])
|
|
132
|
+
except json.JSONDecodeError as e:
|
|
133
|
+
raise LLMError(f"Model did not return valid JSON: {e}") from e
|
|
134
|
+
raise LLMError("Model did not return a JSON object.")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Base + provider implementations
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
class LLMClient:
|
|
141
|
+
"""Abstract base. Subclasses implement :meth:`structured`."""
|
|
142
|
+
|
|
143
|
+
model: str = ""
|
|
144
|
+
|
|
145
|
+
def structured(
|
|
146
|
+
self,
|
|
147
|
+
system: str,
|
|
148
|
+
user: str,
|
|
149
|
+
schema: dict[str, Any],
|
|
150
|
+
max_tokens: int = 8000,
|
|
151
|
+
effort: str = "medium",
|
|
152
|
+
) -> Any:
|
|
153
|
+
raise NotImplementedError
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class AnthropicClient(LLMClient):
|
|
157
|
+
def __init__(
|
|
158
|
+
self, model: str, api_key: Optional[str] = None, base_url: Optional[str] = None
|
|
159
|
+
) -> None:
|
|
160
|
+
try:
|
|
161
|
+
import anthropic
|
|
162
|
+
except ImportError as e: # pragma: no cover
|
|
163
|
+
raise LLMError("The 'anthropic' package is not installed.") from e
|
|
164
|
+
self._anthropic = anthropic
|
|
165
|
+
self.model = model
|
|
166
|
+
kwargs: dict[str, Any] = {}
|
|
167
|
+
if api_key:
|
|
168
|
+
kwargs["api_key"] = api_key
|
|
169
|
+
if base_url:
|
|
170
|
+
kwargs["base_url"] = base_url
|
|
171
|
+
try:
|
|
172
|
+
self._client = anthropic.Anthropic(**kwargs)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
raise LLMError(f"Could not initialise Anthropic client: {e}") from e
|
|
175
|
+
|
|
176
|
+
def structured(self, system, user, schema, max_tokens=8000, effort="medium"):
|
|
177
|
+
output_config: dict[str, Any] = {
|
|
178
|
+
"format": {"type": "json_schema", "schema": schema}
|
|
179
|
+
}
|
|
180
|
+
if self.model in _EFFORT_MODELS:
|
|
181
|
+
output_config["effort"] = effort
|
|
182
|
+
def _create():
|
|
183
|
+
return self._client.messages.create(
|
|
184
|
+
model=self.model,
|
|
185
|
+
max_tokens=max_tokens,
|
|
186
|
+
system=system,
|
|
187
|
+
messages=[{"role": "user", "content": user}],
|
|
188
|
+
output_config=output_config,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
resp = _retry(_create)
|
|
193
|
+
except self._anthropic.APIStatusError as e:
|
|
194
|
+
raise LLMError(f"Anthropic API error ({e.status_code}): {e.message}") from e
|
|
195
|
+
except self._anthropic.APIConnectionError as e:
|
|
196
|
+
raise LLMError(f"Network error talking to Anthropic: {e}") from e
|
|
197
|
+
|
|
198
|
+
if resp.stop_reason == "refusal":
|
|
199
|
+
raise LLMError("The model declined to respond to this request.")
|
|
200
|
+
text = next((b.text for b in resp.content if b.type == "text"), None)
|
|
201
|
+
return _extract_json(text)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class OpenAIClient(LLMClient):
|
|
205
|
+
"""OpenAI or any OpenAI-compatible endpoint (via ``base_url``)."""
|
|
206
|
+
|
|
207
|
+
def __init__(
|
|
208
|
+
self, model: str, api_key: Optional[str] = None, base_url: Optional[str] = None
|
|
209
|
+
) -> None:
|
|
210
|
+
try:
|
|
211
|
+
from openai import OpenAI
|
|
212
|
+
except ImportError as e:
|
|
213
|
+
raise LLMError(
|
|
214
|
+
"The 'openai' package is not installed. Install it with "
|
|
215
|
+
"`uv sync --extra openai`."
|
|
216
|
+
) from e
|
|
217
|
+
self.model = model
|
|
218
|
+
kwargs: dict[str, Any] = {
|
|
219
|
+
"api_key": api_key or os.environ.get("OPENAI_API_KEY") or "missing"
|
|
220
|
+
}
|
|
221
|
+
if base_url:
|
|
222
|
+
kwargs["base_url"] = base_url
|
|
223
|
+
try:
|
|
224
|
+
self._client = OpenAI(**kwargs)
|
|
225
|
+
except Exception as e:
|
|
226
|
+
raise LLMError(f"Could not initialise OpenAI client: {e}") from e
|
|
227
|
+
|
|
228
|
+
def structured(self, system, user, schema, max_tokens=8000, effort="medium"):
|
|
229
|
+
messages = [
|
|
230
|
+
{"role": "system", "content": system},
|
|
231
|
+
{"role": "user", "content": user + "\n\n" + _schema_hint(schema)},
|
|
232
|
+
]
|
|
233
|
+
content = _openai_compat_call(
|
|
234
|
+
self._client.chat.completions.create, self.model, messages, max_tokens
|
|
235
|
+
)
|
|
236
|
+
return _extract_json(content)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class LiteLLMClient(LLMClient):
|
|
240
|
+
"""Unified access to many providers via the litellm SDK + a custom endpoint."""
|
|
241
|
+
|
|
242
|
+
def __init__(
|
|
243
|
+
self, model: str, api_key: Optional[str] = None, base_url: Optional[str] = None
|
|
244
|
+
) -> None:
|
|
245
|
+
try:
|
|
246
|
+
import litellm
|
|
247
|
+
except ImportError as e:
|
|
248
|
+
raise LLMError(
|
|
249
|
+
"The 'litellm' package is not installed. Install it with "
|
|
250
|
+
"`uv sync --extra litellm`."
|
|
251
|
+
) from e
|
|
252
|
+
if not model:
|
|
253
|
+
raise LLMError("A model name is required for the LiteLLM provider.")
|
|
254
|
+
self._litellm = litellm
|
|
255
|
+
self.model = model
|
|
256
|
+
self._api_key = api_key
|
|
257
|
+
self._base_url = base_url
|
|
258
|
+
|
|
259
|
+
def structured(self, system, user, schema, max_tokens=8000, effort="medium"):
|
|
260
|
+
messages = [
|
|
261
|
+
{"role": "system", "content": system},
|
|
262
|
+
{"role": "user", "content": user + "\n\n" + _schema_hint(schema)},
|
|
263
|
+
]
|
|
264
|
+
extra: dict[str, Any] = {}
|
|
265
|
+
if self._base_url:
|
|
266
|
+
extra["api_base"] = self._base_url
|
|
267
|
+
if self._api_key:
|
|
268
|
+
extra["api_key"] = self._api_key
|
|
269
|
+
content = _openai_compat_call(
|
|
270
|
+
self._litellm.completion, self.model, messages, max_tokens, **extra
|
|
271
|
+
)
|
|
272
|
+
return _extract_json(content)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _openai_compat_call(create_fn, model, messages, max_tokens, **extra) -> str:
|
|
276
|
+
"""Call an OpenAI-style ``create`` with graceful fallbacks.
|
|
277
|
+
|
|
278
|
+
Tries JSON-object mode + ``temperature=0`` first, then progressively drops
|
|
279
|
+
parameters that some models/endpoints reject (reasoning models disallow
|
|
280
|
+
``temperature``; some proxies disallow ``response_format``).
|
|
281
|
+
"""
|
|
282
|
+
attempts = [
|
|
283
|
+
{"response_format": {"type": "json_object"}, "temperature": 0},
|
|
284
|
+
{"temperature": 0},
|
|
285
|
+
{},
|
|
286
|
+
]
|
|
287
|
+
last_err: Optional[Exception] = None
|
|
288
|
+
for opts in attempts:
|
|
289
|
+
def _create():
|
|
290
|
+
resp = create_fn(
|
|
291
|
+
model=model,
|
|
292
|
+
messages=messages,
|
|
293
|
+
max_tokens=max_tokens,
|
|
294
|
+
**opts,
|
|
295
|
+
**extra,
|
|
296
|
+
)
|
|
297
|
+
return resp.choices[0].message.content
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
# Retry transient errors (rate limit / 5xx / timeout) for these opts;
|
|
301
|
+
# a non-transient error (e.g. param rejected) falls through to the
|
|
302
|
+
# next, less-demanding attempt.
|
|
303
|
+
return _retry(_create)
|
|
304
|
+
except Exception as e: # noqa: BLE001 - provider exceptions vary widely
|
|
305
|
+
last_err = e
|
|
306
|
+
raise LLMError(f"LLM request failed: {last_err}")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
# Factory + key detection
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
def make_client(
|
|
313
|
+
provider: str,
|
|
314
|
+
model: str,
|
|
315
|
+
api_key: Optional[str] = None,
|
|
316
|
+
base_url: Optional[str] = None,
|
|
317
|
+
) -> LLMClient:
|
|
318
|
+
api_key = (api_key or "").strip() or None
|
|
319
|
+
base_url = (base_url or "").strip() or None
|
|
320
|
+
if provider == "anthropic":
|
|
321
|
+
return AnthropicClient(model, api_key, base_url)
|
|
322
|
+
if provider == "openai":
|
|
323
|
+
return OpenAIClient(model, api_key, base_url)
|
|
324
|
+
if provider == "litellm":
|
|
325
|
+
return LiteLLMClient(model, api_key, base_url)
|
|
326
|
+
raise LLMError(f"Unknown provider: {provider}")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def env_key_for(provider: str) -> Optional[str]:
|
|
330
|
+
if provider == "anthropic":
|
|
331
|
+
return os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN")
|
|
332
|
+
if provider == "openai":
|
|
333
|
+
return os.environ.get("OPENAI_API_KEY")
|
|
334
|
+
if provider == "litellm":
|
|
335
|
+
return os.environ.get("LITELLM_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
336
|
+
return None
|
conflens/models.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Shared data structures used across the pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Paper:
|
|
12
|
+
"""A single paper scraped from the anthology."""
|
|
13
|
+
|
|
14
|
+
paper_id: str # e.g. "2024.acl-long.1"
|
|
15
|
+
title: str
|
|
16
|
+
url: str # canonical landing page
|
|
17
|
+
pdf_url: str
|
|
18
|
+
authors: list[str] = field(default_factory=list)
|
|
19
|
+
abstract: str = ""
|
|
20
|
+
|
|
21
|
+
# Filled in by the classifier
|
|
22
|
+
relevant: Optional[bool] = None
|
|
23
|
+
confidence: Optional[float] = None
|
|
24
|
+
reason: str = ""
|
|
25
|
+
|
|
26
|
+
# Filled in by topic modelling — a paper may belong to more than one topic
|
|
27
|
+
# (primary first).
|
|
28
|
+
topic_ids: list[int] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
# Filled in by near-duplicate detection: the representative paper_id of the
|
|
31
|
+
# duplicate group (None if this paper is unique or the group's representative).
|
|
32
|
+
duplicate_of: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def topic_id(self) -> Optional[int]:
|
|
36
|
+
"""Primary topic (first assigned), for backward-compatible access."""
|
|
37
|
+
return self.topic_ids[0] if self.topic_ids else None
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def year(self) -> Optional[int]:
|
|
41
|
+
"""Best-effort publication year parsed from the paper id (e.g. 2024.acl…)."""
|
|
42
|
+
m = re.match(r"(19|20)\d{2}", self.paper_id or "")
|
|
43
|
+
return int(m.group(0)) if m else None
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict:
|
|
46
|
+
return asdict(self)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, d: dict) -> "Paper":
|
|
50
|
+
fields = set(cls.__dataclass_fields__)
|
|
51
|
+
return cls(**{k: v for k, v in d.items() if k in fields})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Topic:
|
|
56
|
+
"""A discovered topic within the selected theme."""
|
|
57
|
+
|
|
58
|
+
topic_id: int
|
|
59
|
+
name: str
|
|
60
|
+
description: str = ""
|
|
61
|
+
findings: list[str] = field(default_factory=list) # common findings across the topic's papers
|
|
62
|
+
paper_ids: list[str] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def count(self) -> int:
|
|
66
|
+
return len(self.paper_ids)
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict:
|
|
69
|
+
return asdict(self)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_dict(cls, d: dict) -> "Topic":
|
|
73
|
+
fields = set(cls.__dataclass_fields__)
|
|
74
|
+
return cls(**{k: v for k, v in d.items() if k in fields})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class AnalysisResult:
|
|
79
|
+
"""The full output of a run, ready to render."""
|
|
80
|
+
|
|
81
|
+
theme: str
|
|
82
|
+
event_url: str
|
|
83
|
+
scanned: int = 0
|
|
84
|
+
papers: list[Paper] = field(default_factory=list) # all scanned
|
|
85
|
+
relevant_papers: list[Paper] = field(default_factory=list)
|
|
86
|
+
topics: list[Topic] = field(default_factory=list)
|
|
87
|
+
duplicate_groups: int = 0 # near-duplicate clusters found
|
|
88
|
+
min_confidence: float = 0.5 # threshold applied at run time (display default)
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict:
|
|
91
|
+
return {
|
|
92
|
+
"theme": self.theme,
|
|
93
|
+
"event_url": self.event_url,
|
|
94
|
+
"scanned": self.scanned,
|
|
95
|
+
"relevant_count": len(self.relevant_papers),
|
|
96
|
+
"duplicate_groups": self.duplicate_groups,
|
|
97
|
+
"min_confidence": self.min_confidence,
|
|
98
|
+
"topics": [t.to_dict() for t in self.topics],
|
|
99
|
+
"papers": [p.to_dict() for p in self.relevant_papers],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_dict(cls, d: dict) -> "AnalysisResult":
|
|
104
|
+
"""Rebuild a result from its ``to_dict`` form (for save/load of a run)."""
|
|
105
|
+
relevant = [Paper.from_dict(p) for p in d.get("papers", [])]
|
|
106
|
+
result = cls(
|
|
107
|
+
theme=d.get("theme", ""),
|
|
108
|
+
event_url=d.get("event_url", ""),
|
|
109
|
+
scanned=int(d.get("scanned", 0)),
|
|
110
|
+
papers=relevant, # all scanned isn't persisted; relevant is enough to render
|
|
111
|
+
relevant_papers=relevant,
|
|
112
|
+
topics=[Topic.from_dict(t) for t in d.get("topics", [])],
|
|
113
|
+
duplicate_groups=int(d.get("duplicate_groups", 0)),
|
|
114
|
+
min_confidence=float(d.get("min_confidence", 0.5)),
|
|
115
|
+
)
|
|
116
|
+
return result
|
conflens/pipeline.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Orchestrates scrape -> classify -> topic-model into one analysis run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from . import classifier, dedup
|
|
9
|
+
from . import topics as topic_mod
|
|
10
|
+
from .llm import make_client
|
|
11
|
+
from .models import AnalysisResult
|
|
12
|
+
from .sources import make_source
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AnalysisCancelled(Exception):
|
|
16
|
+
"""Raised cooperatively when the user cancels a run."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Progress:
|
|
21
|
+
"""Mutable progress state shared with the UI."""
|
|
22
|
+
|
|
23
|
+
stage: str = "idle"
|
|
24
|
+
message: str = ""
|
|
25
|
+
fraction: float = 0.0 # 0..1 within the current stage
|
|
26
|
+
log: list[str] = field(default_factory=list)
|
|
27
|
+
done: bool = False
|
|
28
|
+
error: Optional[str] = None
|
|
29
|
+
cancelled: bool = False # set by the UI to request cooperative cancel
|
|
30
|
+
|
|
31
|
+
def set(self, stage: str, message: str, fraction: float = 0.0) -> None:
|
|
32
|
+
self.stage = stage
|
|
33
|
+
self.message = message
|
|
34
|
+
self.fraction = fraction
|
|
35
|
+
self.log.append(message)
|
|
36
|
+
|
|
37
|
+
def check_cancel(self) -> None:
|
|
38
|
+
if self.cancelled:
|
|
39
|
+
raise AnalysisCancelled()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class AnalysisConfig:
|
|
44
|
+
source: str = "aclanthology" # "aclanthology" | "ijcai"
|
|
45
|
+
base_url: str = "https://aclanthology.org"
|
|
46
|
+
event: str = "acl-2026"
|
|
47
|
+
theme: str = "Agentic AI"
|
|
48
|
+
theme_definition: str = "" # optional clarification of what the theme includes/excludes
|
|
49
|
+
provider: str = "openai" # "anthropic" | "openai" | "litellm"
|
|
50
|
+
model: str = "gpt-5.4"
|
|
51
|
+
llm_base_url: str = "" # custom endpoint (LiteLLM / OpenAI-compatible)
|
|
52
|
+
api_key: str = "" # overrides the provider's env var if set
|
|
53
|
+
max_papers: int = 150
|
|
54
|
+
n_topics: int = 8
|
|
55
|
+
min_confidence: float = 0.5
|
|
56
|
+
topic_backend: str = "llm" # "llm" | "bertopic"
|
|
57
|
+
refresh: bool = False # bypass the scrape cache and refetch from source
|
|
58
|
+
source_auth: dict = field(default_factory=dict) # per-source creds (OpenReview)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_analysis(
|
|
62
|
+
cfg: AnalysisConfig,
|
|
63
|
+
progress: Progress,
|
|
64
|
+
cache_dir: Optional[str] = None,
|
|
65
|
+
) -> AnalysisResult:
|
|
66
|
+
"""Execute the full pipeline. Designed to run in a worker thread."""
|
|
67
|
+
scraper = make_source(
|
|
68
|
+
cfg.source, base_url=cfg.base_url, cache_dir=cache_dir, auth=cfg.source_auth
|
|
69
|
+
)
|
|
70
|
+
event_url = scraper.resolve_url(cfg.event)
|
|
71
|
+
result = AnalysisResult(
|
|
72
|
+
theme=cfg.theme, event_url=event_url, min_confidence=cfg.min_confidence
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
# 1. List papers ------------------------------------------------
|
|
77
|
+
origin = "source" if cfg.refresh else "cache or source"
|
|
78
|
+
progress.set("listing", f"Fetching paper list from {event_url} ({origin})", 0.0)
|
|
79
|
+
papers = scraper.list_papers(cfg.event, force_refresh=cfg.refresh)
|
|
80
|
+
if not papers:
|
|
81
|
+
hints = {
|
|
82
|
+
"aclanthology": (
|
|
83
|
+
" The proceedings may not be published yet — try a past event "
|
|
84
|
+
"such as 'acl-2024'."
|
|
85
|
+
),
|
|
86
|
+
"emnlp": (
|
|
87
|
+
" The proceedings may not be published yet — try a past event "
|
|
88
|
+
"such as 'emnlp-2023'."
|
|
89
|
+
),
|
|
90
|
+
"naacl": (
|
|
91
|
+
" The proceedings may not be published yet — try a past event "
|
|
92
|
+
"such as 'naacl-2024'."
|
|
93
|
+
),
|
|
94
|
+
"ijcai": " Check the URL points at the accepted-papers page.",
|
|
95
|
+
"openreview": (
|
|
96
|
+
" Check the venue id (e.g. 'ICLR.cc/2024/Conference' or "
|
|
97
|
+
"'NeurIPS.cc/2024/Conference') — decisions may not be posted yet."
|
|
98
|
+
),
|
|
99
|
+
"pscc": (
|
|
100
|
+
" PSCC is biennial — try a year that was held, e.g. '2024' or "
|
|
101
|
+
"'2022'."
|
|
102
|
+
),
|
|
103
|
+
"isgteurope": (
|
|
104
|
+
" Check the venue + year (e.g. 'isgteurope 2024') — DBLP may "
|
|
105
|
+
"not have indexed that edition yet."
|
|
106
|
+
),
|
|
107
|
+
}
|
|
108
|
+
hint = hints.get(cfg.source, "")
|
|
109
|
+
progress.set("listing", "No papers found on that page." + hint, 1.0)
|
|
110
|
+
progress.done = True
|
|
111
|
+
return result
|
|
112
|
+
if cfg.max_papers and len(papers) > cfg.max_papers:
|
|
113
|
+
papers = papers[: cfg.max_papers]
|
|
114
|
+
progress.set("listing", f"Found {len(papers)} papers.", 1.0)
|
|
115
|
+
progress.check_cancel()
|
|
116
|
+
|
|
117
|
+
# 2. Abstracts --------------------------------------------------
|
|
118
|
+
def abs_prog(done: int, total: int) -> None:
|
|
119
|
+
progress.check_cancel()
|
|
120
|
+
progress.set("abstracts", f"Fetching abstracts ({done}/{total})", done / max(total, 1))
|
|
121
|
+
|
|
122
|
+
scraper.enrich_abstracts(papers, progress=abs_prog, force_refresh=cfg.refresh)
|
|
123
|
+
result.papers = papers
|
|
124
|
+
result.scanned = len(papers)
|
|
125
|
+
result.duplicate_groups = dedup.annotate_duplicates(papers)
|
|
126
|
+
if result.duplicate_groups:
|
|
127
|
+
progress.set("listing", f"Flagged {result.duplicate_groups} near-duplicate group(s).", 1.0)
|
|
128
|
+
progress.check_cancel()
|
|
129
|
+
|
|
130
|
+
# 3. Classify ---------------------------------------------------
|
|
131
|
+
client = make_client(cfg.provider, cfg.model, cfg.api_key, cfg.llm_base_url)
|
|
132
|
+
|
|
133
|
+
def cls_prog(done: int, total: int) -> None:
|
|
134
|
+
progress.set(
|
|
135
|
+
"classify", f"Classifying for '{cfg.theme}' ({done}/{total})", done / max(total, 1)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Keep every paper the model judges relevant (threshold 0) so the UI can
|
|
139
|
+
# re-apply the confidence cut-off live without a re-run. The run's
|
|
140
|
+
# ``min_confidence`` is carried on the result as the default display cut.
|
|
141
|
+
relevant = classifier.classify_papers(
|
|
142
|
+
client,
|
|
143
|
+
cfg.theme,
|
|
144
|
+
papers,
|
|
145
|
+
min_confidence=0.0,
|
|
146
|
+
progress=cls_prog,
|
|
147
|
+
cache_dir=cache_dir,
|
|
148
|
+
cache_sig=f"{cfg.provider}:{cfg.model}",
|
|
149
|
+
force_refresh=cfg.refresh,
|
|
150
|
+
cancel=progress.check_cancel,
|
|
151
|
+
theme_definition=cfg.theme_definition,
|
|
152
|
+
)
|
|
153
|
+
result.relevant_papers = relevant
|
|
154
|
+
at_threshold = sum(1 for p in relevant if (p.confidence or 0) >= cfg.min_confidence)
|
|
155
|
+
progress.set(
|
|
156
|
+
"classify",
|
|
157
|
+
f"{at_threshold} of {len(papers)} papers match the theme "
|
|
158
|
+
f"(≥{cfg.min_confidence:.2f} confidence).",
|
|
159
|
+
1.0,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if not relevant:
|
|
163
|
+
progress.set("topics", "No matching papers — nothing to model.", 1.0)
|
|
164
|
+
progress.done = True
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
# 4. Topic modelling --------------------------------------------
|
|
168
|
+
def top_prog(done: int, total: int) -> None:
|
|
169
|
+
progress.set("topics", f"Assigning topics ({done}/{total})", done / max(total, 1))
|
|
170
|
+
|
|
171
|
+
progress.set("topics", "Discovering topics…", 0.0)
|
|
172
|
+
result.topics = topic_mod.model_topics(
|
|
173
|
+
cfg.topic_backend,
|
|
174
|
+
client,
|
|
175
|
+
cfg.theme,
|
|
176
|
+
relevant,
|
|
177
|
+
n_topics=cfg.n_topics,
|
|
178
|
+
progress=top_prog,
|
|
179
|
+
theme_definition=cfg.theme_definition,
|
|
180
|
+
)
|
|
181
|
+
progress.check_cancel()
|
|
182
|
+
|
|
183
|
+
# 5. Per-topic synthesis (description + common findings) --------
|
|
184
|
+
def sum_prog(done: int, total: int) -> None:
|
|
185
|
+
progress.set("summarize", f"Summarising topics ({done}/{total})", done / max(total, 1))
|
|
186
|
+
|
|
187
|
+
progress.set("summarize", "Summarising topics…", 0.0)
|
|
188
|
+
topic_mod.summarize_topics(
|
|
189
|
+
client,
|
|
190
|
+
cfg.theme,
|
|
191
|
+
result.topics,
|
|
192
|
+
relevant,
|
|
193
|
+
progress=sum_prog,
|
|
194
|
+
cache_dir=cache_dir,
|
|
195
|
+
cache_sig=f"{cfg.provider}:{cfg.model}",
|
|
196
|
+
force_refresh=cfg.refresh,
|
|
197
|
+
cancel=progress.check_cancel,
|
|
198
|
+
theme_definition=cfg.theme_definition,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
progress.set("done", f"Done — {len(result.topics)} topics across {len(relevant)} papers.", 1.0)
|
|
202
|
+
progress.done = True
|
|
203
|
+
return result
|
|
204
|
+
except AnalysisCancelled:
|
|
205
|
+
progress.set("cancelled", "Cancelled.", progress.fraction)
|
|
206
|
+
progress.done = True
|
|
207
|
+
return result
|