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