citegraph 0.2.0__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.
- citegraph/__init__.py +44 -0
- citegraph/_progress.py +74 -0
- citegraph/annotations.py +183 -0
- citegraph/author_export.py +294 -0
- citegraph/author_overrides.py +143 -0
- citegraph/authors.py +1997 -0
- citegraph/cli.py +926 -0
- citegraph/cocitation_stats.py +370 -0
- citegraph/config.py +73 -0
- citegraph/cost_estimation.py +259 -0
- citegraph/dedup.py +498 -0
- citegraph/enrich.py +972 -0
- citegraph/extract_metadata.py +81 -0
- citegraph/extract_references.py +376 -0
- citegraph/graph.py +660 -0
- citegraph/html_report.py +1787 -0
- citegraph/identity.py +199 -0
- citegraph/ids.py +106 -0
- citegraph/io.py +566 -0
- citegraph/jel.py +167 -0
- citegraph/journals.py +182 -0
- citegraph/llm.py +186 -0
- citegraph/pdf_to_markdown.py +394 -0
- citegraph/pipeline.py +997 -0
- citegraph/plotting.py +1214 -0
- citegraph/reports.py +214 -0
- citegraph/schemas.py +89 -0
- citegraph/webui.py +624 -0
- citegraph-0.2.0.dist-info/METADATA +870 -0
- citegraph-0.2.0.dist-info/RECORD +33 -0
- citegraph-0.2.0.dist-info/WHEEL +4 -0
- citegraph-0.2.0.dist-info/entry_points.txt +2 -0
- citegraph-0.2.0.dist-info/licenses/LICENSE +21 -0
citegraph/identity.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Persist slug identities using observed membership, without freezing clusters."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
from citegraph.io import fingerprint, metadata_fingerprint
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def reconcile(groups: dict[str, set[str]], previous: dict | None = None, *,
|
|
11
|
+
pinned: dict[str, str] | None = None) -> tuple[dict, dict, list]:
|
|
12
|
+
"""Keep unambiguous continuations; retire splits and redirect merged IDs.
|
|
13
|
+
|
|
14
|
+
Missing entities remain reserved so removing/reintroducing input cannot give
|
|
15
|
+
an old annotation key to somebody else. Membership hashes are evidence only;
|
|
16
|
+
new entity IDs are always the supplied deterministic slugs (with suffixes).
|
|
17
|
+
|
|
18
|
+
``pinned`` maps a group key to an ID a *user* asserted it continues — an
|
|
19
|
+
alias merge naming both spellings of one person. That is the strongest
|
|
20
|
+
identity evidence available, so a pin claims its ID even when the prior
|
|
21
|
+
group otherwise reads as a split: the child is not arbitrary, it is the
|
|
22
|
+
one the user pointed at. The split is still recorded, as
|
|
23
|
+
``identity_split_resolved``, and the remaining children get fresh IDs.
|
|
24
|
+
Two groups can never claim the same pin.
|
|
25
|
+
"""
|
|
26
|
+
previous = previous if previous is not None else {
|
|
27
|
+
'schema_version': 1, 'entries': {}, 'reserved_ids': [], 'redirects': {}, 'history': []}
|
|
28
|
+
if (not isinstance(previous, dict) or previous.get('schema_version') != 1
|
|
29
|
+
or not isinstance(previous.get('entries'), dict)
|
|
30
|
+
or not isinstance(previous.get('redirects'), dict)
|
|
31
|
+
or not isinstance(previous.get('reserved_ids'), list)
|
|
32
|
+
or not isinstance(previous.get('history'), list)):
|
|
33
|
+
raise ValueError('Invalid identity registry; restore its last valid copy')
|
|
34
|
+
entries = previous['entries']
|
|
35
|
+
if any(not isinstance(key, str) or not isinstance(members, list)
|
|
36
|
+
or any(not isinstance(member, str) for member in members)
|
|
37
|
+
for key, members in entries.items()):
|
|
38
|
+
raise ValueError('Invalid identity membership; restore its last valid copy')
|
|
39
|
+
redirects = dict(previous['redirects'])
|
|
40
|
+
if any(not isinstance(k, str) or not isinstance(v, str) for k, v in redirects.items()):
|
|
41
|
+
raise ValueError('Invalid identity redirects')
|
|
42
|
+
for key in redirects:
|
|
43
|
+
resolve_redirect(key, redirects)
|
|
44
|
+
reserved = set(previous['reserved_ids']) | set(entries) | set(redirects) | set(redirects.values())
|
|
45
|
+
old_by_member: dict[str, set[str]] = defaultdict(set)
|
|
46
|
+
for old, members in entries.items():
|
|
47
|
+
for member in members:
|
|
48
|
+
old_by_member[member].add(old)
|
|
49
|
+
ancestors = {new: set().union(*(old_by_member[m] for m in members))
|
|
50
|
+
for new, members in groups.items()}
|
|
51
|
+
descendants: dict[str, set[str]] = defaultdict(set)
|
|
52
|
+
for new, old_ids in ancestors.items():
|
|
53
|
+
for old in old_ids:
|
|
54
|
+
descendants[old].add(new)
|
|
55
|
+
splits = {old for old, children in descendants.items() if len(children) > 1}
|
|
56
|
+
assigned: dict[str, str] = {}
|
|
57
|
+
# A user-curated merge outranks the split rule; honour it before anything
|
|
58
|
+
# else can reserve the ID. Pinned IDs stay in ``splits`` so no *other*
|
|
59
|
+
# child can pick them up as an ordinary continuation.
|
|
60
|
+
claimed: dict[str, str] = {}
|
|
61
|
+
for new in sorted(groups):
|
|
62
|
+
target = pinned.get(new) if pinned else None
|
|
63
|
+
if target is None or target in claimed:
|
|
64
|
+
continue
|
|
65
|
+
claimed[target] = new
|
|
66
|
+
assigned[new] = target
|
|
67
|
+
# Reserve all continuations before allocating any new collision suffix.
|
|
68
|
+
for new in sorted(groups):
|
|
69
|
+
if new in assigned:
|
|
70
|
+
continue
|
|
71
|
+
candidates = ancestors[new] - splits - set(claimed)
|
|
72
|
+
if candidates:
|
|
73
|
+
assigned[new] = new if new in candidates else min(candidates)
|
|
74
|
+
used = set(assigned.values())
|
|
75
|
+
for new in sorted(groups):
|
|
76
|
+
if new in assigned:
|
|
77
|
+
continue
|
|
78
|
+
candidate, suffix = new, 1
|
|
79
|
+
while candidate in reserved or candidate in used:
|
|
80
|
+
candidate = f'{new}-{suffix}'
|
|
81
|
+
suffix += 1
|
|
82
|
+
assigned[new] = candidate
|
|
83
|
+
used.add(candidate)
|
|
84
|
+
changes = []
|
|
85
|
+
retained = {old: list(members) for old, members in entries.items()
|
|
86
|
+
if old not in descendants}
|
|
87
|
+
for new, cid in assigned.items():
|
|
88
|
+
# Preserve disappeared members of a continuation for future restoration.
|
|
89
|
+
members = set(groups[new])
|
|
90
|
+
inherited = ancestors[new] - splits
|
|
91
|
+
if claimed.get(cid) == new and cid in entries:
|
|
92
|
+
inherited = inherited | {cid}
|
|
93
|
+
for old in inherited:
|
|
94
|
+
members.update(entries[old])
|
|
95
|
+
if old != cid:
|
|
96
|
+
redirects[old] = cid
|
|
97
|
+
changes.append({'decision': 'identity_merge', 'old_id': old, 'new_id': cid})
|
|
98
|
+
retained[cid] = sorted(members)
|
|
99
|
+
for old in sorted(splits):
|
|
100
|
+
entry = {'decision': 'identity_split', 'old_id': old,
|
|
101
|
+
'new_ids': sorted(assigned[new] for new in descendants[old])}
|
|
102
|
+
if old in claimed:
|
|
103
|
+
# A user merge decided which child continues; say so in the audit
|
|
104
|
+
# rather than reporting the ID as retired.
|
|
105
|
+
entry['decision'] = 'identity_split_resolved'
|
|
106
|
+
entry['assigned_id'] = old
|
|
107
|
+
changes.append(entry)
|
|
108
|
+
# Redirects ending at a split remain retired, never choose a split child.
|
|
109
|
+
state = {'schema_version': 1, 'entries': retained,
|
|
110
|
+
'reserved_ids': sorted(reserved | used), 'redirects': redirects,
|
|
111
|
+
'history': previous['history'] + changes}
|
|
112
|
+
return assigned, state, changes
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def resolve_redirect(identifier: str, redirects: dict[str, str]) -> str:
|
|
116
|
+
seen = set()
|
|
117
|
+
while identifier in redirects:
|
|
118
|
+
if identifier in seen:
|
|
119
|
+
raise ValueError('Invalid identity redirect cycle')
|
|
120
|
+
seen.add(identifier)
|
|
121
|
+
identifier = redirects[identifier]
|
|
122
|
+
return identifier
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def occurrence_key(record_id: str, position: int, raw_author: str) -> str:
|
|
126
|
+
return json.dumps([str(record_id), int(position), raw_author], ensure_ascii=False)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def remap_author_registry(state: dict, work_redirects: dict) -> dict:
|
|
130
|
+
"""Apply confirmed work redirects to author evidence, never split mappings."""
|
|
131
|
+
result = dict(state)
|
|
132
|
+
for section in ('algorithm', 'published'):
|
|
133
|
+
if section not in state:
|
|
134
|
+
continue
|
|
135
|
+
registry = dict(state[section])
|
|
136
|
+
entries = {}
|
|
137
|
+
for aid, members in registry['entries'].items():
|
|
138
|
+
entries[aid] = []
|
|
139
|
+
for member in members:
|
|
140
|
+
wid, position, raw = json.loads(member)
|
|
141
|
+
entries[aid].append(occurrence_key(resolve_redirect(wid, work_redirects), position, raw))
|
|
142
|
+
registry['entries'] = entries
|
|
143
|
+
result[section] = registry
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def work_groups(works, sources, citations, stats) -> dict[str, set[str]]:
|
|
148
|
+
groups = {str(wid): set() for wid in works.index}
|
|
149
|
+
metadata_groups: dict[str, set[str]] = defaultdict(set)
|
|
150
|
+
for row, cid in zip(sources.to_dict('records'), stats['source_cluster_ids'], strict=True):
|
|
151
|
+
signature = metadata_fingerprint(row)
|
|
152
|
+
groups[cid].add(fingerprint(['source', row['id'], signature]))
|
|
153
|
+
metadata_groups[signature].add(cid)
|
|
154
|
+
positions: dict[str, int] = defaultdict(int)
|
|
155
|
+
for row, cid in zip(citations.to_dict('records'), stats['citation_cluster_ids'], strict=True):
|
|
156
|
+
signature = metadata_fingerprint(row)
|
|
157
|
+
source = str(row['citing_id'])
|
|
158
|
+
groups[cid].add(fingerprint(['citation', source, positions[source], signature]))
|
|
159
|
+
positions[source] += 1
|
|
160
|
+
metadata_groups[signature].add(cid)
|
|
161
|
+
# An exact metadata observation is an anchor only if it names one cluster.
|
|
162
|
+
# This also bootstraps old works tables without inventing past decisions.
|
|
163
|
+
for signature, ids in metadata_groups.items():
|
|
164
|
+
if len(ids) == 1:
|
|
165
|
+
groups[next(iter(ids))].add('metadata:' + signature)
|
|
166
|
+
return groups
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def seed_work_registry(works) -> dict:
|
|
170
|
+
groups = {str(wid): {'metadata:' + metadata_fingerprint(row.to_dict())}
|
|
171
|
+
for wid, row in works.iterrows()}
|
|
172
|
+
return reconcile(groups)[1]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def seed_author_registry(authors, citations, *, works=None, skipped=None) -> dict:
|
|
176
|
+
from citegraph.authors import _row_authors
|
|
177
|
+
|
|
178
|
+
if not authors.index.is_unique or citations.duplicated(['record_id', 'position']).any():
|
|
179
|
+
raise ValueError('Invalid prior author identity: duplicate IDs or occurrences')
|
|
180
|
+
groups = {str(aid): set() for aid in authors.index}
|
|
181
|
+
for row in citations.itertuples(index=False):
|
|
182
|
+
if row.author_id not in groups:
|
|
183
|
+
raise ValueError('Invalid prior author identity: occurrence references an unknown author')
|
|
184
|
+
if works is not None:
|
|
185
|
+
names = _row_authors(works.loc[row.record_id]) if row.record_id in works.index else []
|
|
186
|
+
position = int(row.position)
|
|
187
|
+
if position != row.position or not 0 <= position < len(names) or names[position] != row.raw_author:
|
|
188
|
+
# Older code split comma-bearing author strings differently, so
|
|
189
|
+
# a handful of legacy occurrences no longer describe the works
|
|
190
|
+
# table. Callers that pass `skipped` keep the rest of the
|
|
191
|
+
# registry: dropping an occurrence only removes an anchor, and
|
|
192
|
+
# a cluster with no surviving ancestor is allocated a fresh ID
|
|
193
|
+
# rather than silently continuing the wrong one.
|
|
194
|
+
if skipped is None:
|
|
195
|
+
raise ValueError('Invalid prior author identity: occurrence differs from previous works')
|
|
196
|
+
skipped.append((row.author_id, row.record_id, position, row.raw_author))
|
|
197
|
+
continue
|
|
198
|
+
groups[row.author_id].add(occurrence_key(row.record_id, row.position, row.raw_author))
|
|
199
|
+
return reconcile(groups)[1]
|
citegraph/ids.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Stable identifier generation for works.
|
|
2
|
+
|
|
3
|
+
We don't want IDs to change between runs, so they are derived from the
|
|
4
|
+
content (first author + year + a title prefix) rather than from row index.
|
|
5
|
+
The single ``w-`` prefix is role-free on purpose: a work's role (core
|
|
6
|
+
source vs cited stub) can change over its life; its identity must not.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from slugify import slugify
|
|
14
|
+
|
|
15
|
+
_NON_ALPHA = re.compile(r"[^a-zA-Z]+")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _first_author_token(authors: str | list[str]) -> str:
|
|
19
|
+
"""Return a single lower-case surname token for the first author.
|
|
20
|
+
|
|
21
|
+
Handles both ``"Ostrom, E."`` (last-name-first) and ``"Elinor Ostrom"``
|
|
22
|
+
(first-name-first) styles. For multi-author lists, only the first
|
|
23
|
+
author is considered.
|
|
24
|
+
"""
|
|
25
|
+
if isinstance(authors, list):
|
|
26
|
+
first = authors[0] if authors else ""
|
|
27
|
+
elif not isinstance(authors, str):
|
|
28
|
+
# NaN or other non-string scalars from pandas
|
|
29
|
+
return "unknown"
|
|
30
|
+
else:
|
|
31
|
+
first = authors.split(";")[0] if authors else ""
|
|
32
|
+
if "," not in first and " and " in first:
|
|
33
|
+
first = first.split(" and ")[0]
|
|
34
|
+
|
|
35
|
+
first = first.strip()
|
|
36
|
+
if not first:
|
|
37
|
+
return "unknown"
|
|
38
|
+
|
|
39
|
+
if "," in first:
|
|
40
|
+
surname = first.split(",")[0]
|
|
41
|
+
else:
|
|
42
|
+
tokens = [t for t in _NON_ALPHA.split(first) if t]
|
|
43
|
+
surname = tokens[-1] if tokens else ""
|
|
44
|
+
|
|
45
|
+
surname = "".join(ch for ch in surname if ch.isalpha())
|
|
46
|
+
return surname.lower() or "unknown"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def make_work_id(
|
|
50
|
+
authors: str | list[str],
|
|
51
|
+
year: int | str | None,
|
|
52
|
+
title: str,
|
|
53
|
+
) -> str:
|
|
54
|
+
"""Build a stable, slug-style work id from bibliographic fields.
|
|
55
|
+
|
|
56
|
+
Examples
|
|
57
|
+
--------
|
|
58
|
+
>>> make_work_id("Ostrom, E.", 1990, "Governing the Commons")
|
|
59
|
+
'w-ostrom-1990-governing-the-commons'
|
|
60
|
+
"""
|
|
61
|
+
author = _first_author_token(authors)
|
|
62
|
+
year_token = str(year) if year not in (None, "") else "nd"
|
|
63
|
+
title_str = title if isinstance(title, str) else ""
|
|
64
|
+
title_slug = slugify(title_str or "untitled", max_length=40, word_boundary=True) or "untitled"
|
|
65
|
+
return f"w-{author}-{year_token}-{title_slug}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def assign_source_ids(records: list[dict], previous_registry: dict | None = None) -> tuple[list[dict], dict]:
|
|
69
|
+
"""Allocate source observation IDs without dropping collisions or reusing history."""
|
|
70
|
+
from citegraph.io import metadata_fingerprint
|
|
71
|
+
|
|
72
|
+
previous = previous_registry or {}
|
|
73
|
+
entries = [dict(e) for e in previous.get("entries", [])]
|
|
74
|
+
reserved = set(previous.get("reserved_ids", [])) | {e["id"] for e in entries}
|
|
75
|
+
lookup = {(e["source_file"], e["metadata_fingerprint"]): e["id"] for e in entries}
|
|
76
|
+
if len({e["id"] for e in entries}) != len(entries):
|
|
77
|
+
raise ValueError("source_ids.json assigns one ID to multiple observations; restore the registry")
|
|
78
|
+
result = [dict(r) for r in records]
|
|
79
|
+
collision_ids = set(previous.get("collision_ids", []))
|
|
80
|
+
# Base IDs are not hashes; sort complete metadata first so allocation is reproducible.
|
|
81
|
+
order = sorted(range(len(result)), key=lambda i: (
|
|
82
|
+
str(result[i].get("Title", "")), str(result[i].get("Authors_List", "")),
|
|
83
|
+
str(result[i].get("Journal", "")), str(result[i].get("Year", "")),
|
|
84
|
+
str(result[i]["source_file"])))
|
|
85
|
+
for i in order:
|
|
86
|
+
rec = result[i]
|
|
87
|
+
signature = metadata_fingerprint(rec)
|
|
88
|
+
key = (str(rec["source_file"]), signature)
|
|
89
|
+
if key in lookup:
|
|
90
|
+
rec["id"] = lookup[key]
|
|
91
|
+
continue
|
|
92
|
+
base = make_work_id(rec.get("Authors_List") or rec.get("Authors", ""), rec.get("Year"), rec.get("Title", ""))
|
|
93
|
+
candidate = base
|
|
94
|
+
suffix = 2
|
|
95
|
+
while candidate in reserved:
|
|
96
|
+
collision_ids.add(base)
|
|
97
|
+
candidate = f"{base}-{suffix}"
|
|
98
|
+
suffix += 1
|
|
99
|
+
if candidate != base:
|
|
100
|
+
collision_ids.add(candidate)
|
|
101
|
+
reserved.add(candidate)
|
|
102
|
+
rec["id"] = candidate
|
|
103
|
+
lookup[key] = candidate
|
|
104
|
+
entries.append(dict(source_file=key[0], metadata_fingerprint=signature, id=candidate))
|
|
105
|
+
return result, dict(schema_version=1, entries=entries, reserved_ids=sorted(reserved),
|
|
106
|
+
collision_ids=sorted(collision_ids))
|