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