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/bibtex.py ADDED
@@ -0,0 +1,56 @@
1
+ """Build a BibTeX bibliography from analysis results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from .models import AnalysisResult, Paper
8
+
9
+ _YEAR = re.compile(r"(19|20)\d{2}")
10
+
11
+
12
+ def _cite_key(paper: Paper) -> str:
13
+ key = re.sub(r"[^0-9A-Za-z]", "", paper.paper_id)
14
+ if not key:
15
+ key = "paper"
16
+ if not key[0].isalpha():
17
+ key = "p" + key
18
+ return key
19
+
20
+
21
+ def _year(paper: Paper) -> str:
22
+ m = _YEAR.search(paper.paper_id)
23
+ return m.group(0) if m else ""
24
+
25
+
26
+ def _clean(value: str) -> str:
27
+ # Strip characters that would break a BibTeX field; keep it simple and safe.
28
+ return value.replace("{", "").replace("}", "").replace("\\", "").strip()
29
+
30
+
31
+ def build_bibtex(result: AnalysisResult) -> str:
32
+ """Return a BibTeX string with one ``@inproceedings`` entry per relevant paper."""
33
+ entries: list[str] = []
34
+ seen: set[str] = set()
35
+ for paper in result.relevant_papers:
36
+ key = base = _cite_key(paper)
37
+ suffix = ord("a")
38
+ while key in seen: # ensure unique keys
39
+ key = f"{base}{chr(suffix)}"
40
+ suffix += 1
41
+ seen.add(key)
42
+
43
+ fields = [f" title = {{{{{_clean(paper.title)}}}}}"]
44
+ if paper.authors:
45
+ authors = " and ".join(_clean(a) for a in paper.authors)
46
+ fields.append(f" author = {{{authors}}}")
47
+ year = _year(paper)
48
+ if year:
49
+ fields.append(f" year = {{{year}}}")
50
+ if paper.url:
51
+ fields.append(f" url = {{{paper.url}}}")
52
+
53
+ entries.append("@inproceedings{" + key + ",\n" + ",\n".join(fields) + "\n}")
54
+
55
+ header = f"% {len(entries)} papers matching \"{_clean(result.theme)}\"\n"
56
+ return header + "\n\n".join(entries) + ("\n" if entries else "")
conflens/cache.py ADDED
@@ -0,0 +1,36 @@
1
+ """Shared on-disk cache location and a helper to clear it.
2
+
3
+ The cache holds scraped listings, per-paper abstracts/authors, and classification
4
+ results — all under one directory so it can be wiped in one go.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ from typing import Optional
12
+
13
+
14
+ def default_cache_dir() -> str:
15
+ return os.path.join(os.path.expanduser("~"), ".cache", "conflens")
16
+
17
+
18
+ def clear_cache(cache_dir: Optional[str] = None) -> tuple[str, int]:
19
+ """Delete everything inside the cache directory.
20
+
21
+ Returns ``(cache_dir, items_removed)``. The directory itself is kept.
22
+ """
23
+ target = cache_dir or default_cache_dir()
24
+ removed = 0
25
+ if os.path.isdir(target):
26
+ for name in os.listdir(target):
27
+ path = os.path.join(target, name)
28
+ try:
29
+ if os.path.isdir(path) and not os.path.islink(path):
30
+ shutil.rmtree(path)
31
+ else:
32
+ os.remove(path)
33
+ removed += 1
34
+ except OSError:
35
+ pass
36
+ return target, removed
conflens/classifier.py ADDED
@@ -0,0 +1,174 @@
1
+ """LLM-based relevance classification of papers against a theme."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from typing import Callable, Optional
9
+
10
+ from .llm import LLMClient
11
+ from .models import Paper
12
+
13
+ _BATCH_SIZE = 20
14
+ _ABSTRACT_CHARS = 900 # keep prompts cheap; the title + opening of the abstract is plenty
15
+
16
+ _SYSTEM = (
17
+ "You are an expert research librarian for NLP and machine-learning venues. "
18
+ "You decide whether each paper is genuinely about a given theme. Judge by the "
19
+ "paper's actual contribution, not incidental keyword matches. Be precise: a paper "
20
+ "that merely mentions the theme in passing is NOT relevant; a paper whose core "
21
+ "contribution advances the theme IS relevant."
22
+ )
23
+
24
+ _SCHEMA = {
25
+ "type": "object",
26
+ "properties": {
27
+ "results": {
28
+ "type": "array",
29
+ "items": {
30
+ "type": "object",
31
+ "properties": {
32
+ "index": {"type": "integer"},
33
+ "relevant": {"type": "boolean"},
34
+ "confidence": {"type": "number"},
35
+ "reason": {"type": "string"},
36
+ },
37
+ "required": ["index", "relevant", "confidence", "reason"],
38
+ "additionalProperties": False,
39
+ },
40
+ }
41
+ },
42
+ "required": ["results"],
43
+ "additionalProperties": False,
44
+ }
45
+
46
+
47
+ def _batch_prompt(theme: str, batch: list[Paper], offset: int, theme_definition: str = "") -> str:
48
+ lines = [f'Theme: "{theme}"']
49
+ if theme_definition and theme_definition.strip():
50
+ lines.append(f"Scope of the theme (what counts / doesn't): {theme_definition.strip()}")
51
+ lines += [
52
+ "",
53
+ "For EACH paper below, decide whether its core contribution is about this "
54
+ "theme. Return relevant (true/false), a confidence in [0,1], and a one-sentence "
55
+ "reason. Use the paper's `index` exactly as given.",
56
+ "",
57
+ ]
58
+ for i, p in enumerate(batch):
59
+ idx = offset + i
60
+ abstract = (p.abstract or "").strip()
61
+ if len(abstract) > _ABSTRACT_CHARS:
62
+ abstract = abstract[:_ABSTRACT_CHARS] + "…"
63
+ lines.append(f"[{idx}] Title: {p.title}")
64
+ if abstract:
65
+ lines.append(f" Abstract: {abstract}")
66
+ lines.append("")
67
+ return "\n".join(lines)
68
+
69
+
70
+ def _content_hash(paper: Paper) -> str:
71
+ raw = f"{paper.title}\n{paper.abstract or ''}"
72
+ return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
73
+
74
+
75
+ def _cache_path(cache_dir: str, cache_sig: str, theme: str, theme_definition: str = "") -> str:
76
+ digest = hashlib.sha1(
77
+ f"{cache_sig}|{theme}|{theme_definition}".encode("utf-8")
78
+ ).hexdigest()[:16]
79
+ return os.path.join(cache_dir, f"classify_{digest}.json")
80
+
81
+
82
+ def classify_papers(
83
+ client: LLMClient,
84
+ theme: str,
85
+ papers: list[Paper],
86
+ min_confidence: float = 0.5,
87
+ progress: Optional[Callable[[int, int], None]] = None,
88
+ cache_dir: Optional[str] = None,
89
+ cache_sig: str = "",
90
+ force_refresh: bool = False,
91
+ cancel: Optional[Callable[[], None]] = None,
92
+ theme_definition: str = "",
93
+ ) -> list[Paper]:
94
+ """Annotate every paper with relevance/confidence/reason in place.
95
+
96
+ The *raw* model judgement (relevant flag, confidence, reason) is cached on
97
+ disk, keyed by provider+model, theme, and the paper's title+abstract hash —
98
+ so re-running the same theme/model is free, while changing any of those
99
+ re-classifies only the affected papers. ``min_confidence`` is applied at read
100
+ time, so adjusting the threshold never needs a re-call.
101
+
102
+ Returns the list of papers judged relevant at or above ``min_confidence``.
103
+ """
104
+ total = len(papers)
105
+
106
+ # Load any cached judgements for this (provider+model, theme).
107
+ cache: dict[str, dict] = {}
108
+ cache_path = None
109
+ if cache_dir:
110
+ os.makedirs(cache_dir, exist_ok=True)
111
+ cache_path = _cache_path(cache_dir, cache_sig, theme, theme_definition)
112
+ if not force_refresh and os.path.exists(cache_path):
113
+ try:
114
+ with open(cache_path, "r", encoding="utf-8") as fh:
115
+ cache = json.load(fh)
116
+ except (OSError, json.JSONDecodeError):
117
+ cache = {}
118
+
119
+ # raw[paper_id] = {"relevant": bool, "confidence": float, "reason": str}
120
+ raw: dict[str, dict] = {}
121
+ todo: list[Paper] = []
122
+ for p in papers:
123
+ hit = cache.get(p.paper_id)
124
+ if hit and not force_refresh and hit.get("hash") == _content_hash(p):
125
+ raw[p.paper_id] = hit
126
+ else:
127
+ todo.append(p)
128
+
129
+ done = total - len(todo)
130
+ if progress:
131
+ progress(done, total)
132
+
133
+ def _save() -> None:
134
+ if cache_path:
135
+ try:
136
+ with open(cache_path, "w", encoding="utf-8") as fh:
137
+ json.dump(cache, fh)
138
+ except OSError:
139
+ pass
140
+
141
+ for start in range(0, len(todo), _BATCH_SIZE):
142
+ if cancel:
143
+ cancel()
144
+ batch = todo[start : start + _BATCH_SIZE]
145
+ prompt = _batch_prompt(theme, batch, start, theme_definition)
146
+ data = client.structured(_SYSTEM, prompt, _SCHEMA, max_tokens=4000, effort="low")
147
+ by_index = {r["index"]: r for r in data.get("results", [])}
148
+ for i, paper in enumerate(batch):
149
+ r = by_index.get(start + i) or {}
150
+ entry = {
151
+ "relevant": bool(r.get("relevant")),
152
+ "confidence": float(r.get("confidence", 0.0)),
153
+ "reason": r.get("reason", ""),
154
+ "hash": _content_hash(paper),
155
+ }
156
+ raw[paper.paper_id] = entry
157
+ cache[paper.paper_id] = entry
158
+ done += len(batch)
159
+ if progress:
160
+ progress(min(done, total), total)
161
+ _save() # persist incrementally so a long run is resumable
162
+
163
+ _save()
164
+
165
+ # Apply the confidence threshold to every paper (cached + freshly judged).
166
+ relevant: list[Paper] = []
167
+ for p in papers:
168
+ entry = raw.get(p.paper_id, {})
169
+ p.confidence = float(entry.get("confidence", 0.0))
170
+ p.reason = entry.get("reason", "")
171
+ p.relevant = bool(entry.get("relevant")) and p.confidence >= min_confidence
172
+ if p.relevant:
173
+ relevant.append(p)
174
+ return relevant
conflens/cli.py ADDED
@@ -0,0 +1,75 @@
1
+ """Console entry point for the Conference Paper Analyzer GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from nicegui import ui
8
+
9
+ from .app import create_ui
10
+ from .cache import clear_cache, default_cache_dir
11
+
12
+
13
+ @ui.page("/")
14
+ def _index() -> None:
15
+ create_ui()
16
+
17
+
18
+ def _load_env() -> None:
19
+ """Load a ``.env`` from the current working directory (or above), if present.
20
+
21
+ ``find_dotenv(usecwd=True)`` searches from the directory the user runs
22
+ ``conflens`` in — not the installed package location — so a pip-installed CLI
23
+ picks up a ``.env`` sitting next to where it's launched. Real environment
24
+ variables always take precedence (``load_dotenv`` doesn't override them).
25
+ """
26
+ try:
27
+ from dotenv import find_dotenv, load_dotenv
28
+ except ImportError: # python-dotenv is a dependency, but stay defensive
29
+ return
30
+ path = find_dotenv(usecwd=True)
31
+ if path:
32
+ load_dotenv(path)
33
+
34
+
35
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
36
+ parser = argparse.ArgumentParser(
37
+ prog="conflens",
38
+ description="Browse, theme-classify, and topic-model conference papers.",
39
+ )
40
+ parser.add_argument(
41
+ "--clear-cache",
42
+ action="store_true",
43
+ help="delete cached scrapes and classification results, then exit",
44
+ )
45
+ parser.add_argument(
46
+ "--cache-dir",
47
+ default=None,
48
+ help=f"cache directory (default: {default_cache_dir()})",
49
+ )
50
+ parser.add_argument("--host", default="0.0.0.0", help="host to bind (default: 0.0.0.0)")
51
+ parser.add_argument("--port", type=int, default=6868, help="port to bind (default: 6868)")
52
+ return parser.parse_args(argv)
53
+
54
+
55
+ def main() -> None:
56
+ """Launch the web UI, or run a maintenance command."""
57
+ args = _parse_args()
58
+
59
+ if args.clear_cache:
60
+ target, removed = clear_cache(args.cache_dir)
61
+ print(f"Cleared {removed} item(s) from {target}")
62
+ return
63
+
64
+ _load_env()
65
+ ui.run(
66
+ title="Conference Paper Analyzer",
67
+ host=args.host,
68
+ port=args.port,
69
+ reload=False,
70
+ favicon="🔬",
71
+ )
72
+
73
+
74
+ if __name__ in {"__main__", "__mp_main__"}:
75
+ main()
conflens/dedup.py ADDED
@@ -0,0 +1,100 @@
1
+ """Lightweight near-duplicate detection over papers, by title similarity.
2
+
3
+ Dependency-free: exact-normalised-title grouping catches the common case (the
4
+ same paper appearing in several tracks / with reformatted punctuation), plus a
5
+ bounded fuzzy pass (``difflib`` ratio) within first-token buckets to catch minor
6
+ wording differences. No embeddings, so it stays fast and installs nothing.
7
+
8
+ Each paper in a group of size > 1 (except the representative) gets its
9
+ ``duplicate_of`` set to the representative's ``paper_id``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from difflib import SequenceMatcher
16
+
17
+ from .models import Paper
18
+
19
+ _NON_ALNUM = re.compile(r"[^a-z0-9\s]+")
20
+ _WS = re.compile(r"\s+")
21
+
22
+ _FUZZY_RATIO = 0.92 # normalised-title similarity to treat as a near-duplicate
23
+ _BUCKET_CAP = 400 # skip the fuzzy pass for pathologically large buckets
24
+
25
+
26
+ def _normalise(title: str) -> str:
27
+ return _WS.sub(" ", _NON_ALNUM.sub(" ", title.lower())).strip()
28
+
29
+
30
+ class _DSU:
31
+ def __init__(self, n: int) -> None:
32
+ self.parent = list(range(n))
33
+
34
+ def find(self, x: int) -> int:
35
+ while self.parent[x] != x:
36
+ self.parent[x] = self.parent[self.parent[x]]
37
+ x = self.parent[x]
38
+ return x
39
+
40
+ def union(self, a: int, b: int) -> None:
41
+ ra, rb = self.find(a), self.find(b)
42
+ if ra != rb:
43
+ self.parent[max(ra, rb)] = min(ra, rb) # keep the earlier paper as root
44
+
45
+
46
+ def annotate_duplicates(papers: list[Paper]) -> int:
47
+ """Set ``duplicate_of`` on near-duplicate papers in place.
48
+
49
+ Returns the number of duplicate *groups* (clusters of size > 1).
50
+ """
51
+ n = len(papers)
52
+ for p in papers: # reset any prior annotation
53
+ p.duplicate_of = None
54
+ if n < 2:
55
+ return 0
56
+
57
+ norms = [_normalise(p.title) for p in papers]
58
+ dsu = _DSU(n)
59
+
60
+ # 1. Exact normalised-title matches.
61
+ exact: dict[str, int] = {}
62
+ for i, key in enumerate(norms):
63
+ if not key:
64
+ continue
65
+ if key in exact:
66
+ dsu.union(exact[key], i)
67
+ else:
68
+ exact[key] = i
69
+
70
+ # 2. Bounded fuzzy pass within first-token buckets.
71
+ buckets: dict[str, list[int]] = {}
72
+ for i, key in enumerate(norms):
73
+ if key:
74
+ buckets.setdefault(key.split(" ", 1)[0], []).append(i)
75
+ for members in buckets.values():
76
+ if len(members) < 2 or len(members) > _BUCKET_CAP:
77
+ continue
78
+ for a in range(len(members)):
79
+ for b in range(a + 1, len(members)):
80
+ i, j = members[a], members[b]
81
+ if dsu.find(i) == dsu.find(j):
82
+ continue
83
+ if SequenceMatcher(None, norms[i], norms[j]).ratio() >= _FUZZY_RATIO:
84
+ dsu.union(i, j)
85
+
86
+ # 3. Collect clusters; annotate non-representatives.
87
+ groups: dict[int, list[int]] = {}
88
+ for i in range(n):
89
+ groups.setdefault(dsu.find(i), []).append(i)
90
+
91
+ dup_groups = 0
92
+ for root, members in groups.items():
93
+ if len(members) < 2:
94
+ continue
95
+ dup_groups += 1
96
+ rep_id = papers[root].paper_id
97
+ for idx in members:
98
+ if idx != root:
99
+ papers[idx].duplicate_of = rep_id
100
+ return dup_groups