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/io.py
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
"""Filesystem helpers for the on-disk checkpoints.
|
|
2
|
+
|
|
3
|
+
The pipeline persists every expensive intermediate step under ``out_dir``
|
|
4
|
+
so that re-runs can resume:
|
|
5
|
+
|
|
6
|
+
::
|
|
7
|
+
|
|
8
|
+
out_dir/
|
|
9
|
+
markdown/ # docling output, one .md per PDF
|
|
10
|
+
metadata/<stem>.json # per-source Gemini metadata response
|
|
11
|
+
references/<stem>.json # per-source Gemini references response
|
|
12
|
+
sources.csv # per-PDF source-work metadata (stage 2)
|
|
13
|
+
citations_raw.csv # all extracted citations, before canonicalization
|
|
14
|
+
works.csv # canonical works (ring, source_file, metadata)
|
|
15
|
+
citation_graph.csv # (citing_id, cited_id) work->work edges
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import tempfile
|
|
25
|
+
from collections.abc import Iterable
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import pandas as pd
|
|
31
|
+
from pydantic import BaseModel, ValidationError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class OutLayout:
|
|
36
|
+
"""Standard subdirectory / filename layout under ``out_dir``."""
|
|
37
|
+
|
|
38
|
+
out_dir: Path
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def conversion_cache_json(self) -> Path:
|
|
42
|
+
return self.out_dir / "conversion_cache.json"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def input_manifest_json(self) -> Path:
|
|
46
|
+
return self.out_dir / "input_manifest.json"
|
|
47
|
+
|
|
48
|
+
def markdown_inputs(self) -> list[Path]:
|
|
49
|
+
"""Select active inputs while retaining removed/renamed caches on disk."""
|
|
50
|
+
if not self.input_manifest_json.exists():
|
|
51
|
+
return sorted(self.markdown_dir.glob('*.md'))
|
|
52
|
+
saved = read_json(self.input_manifest_json)
|
|
53
|
+
names = saved.get('markdown_names') if isinstance(saved, dict) else None
|
|
54
|
+
if (not isinstance(names, list) or saved.get('schema_version') != 1
|
|
55
|
+
or any(not isinstance(name, str) or Path(name).name != name
|
|
56
|
+
or not name.endswith('.md') for name in names)
|
|
57
|
+
or len(set(names)) != len(names)):
|
|
58
|
+
raise ValueError('Invalid input_manifest.json; rerun `citegraph convert`')
|
|
59
|
+
paths = [self.markdown_dir / name for name in names]
|
|
60
|
+
if any(not path.is_file() for path in paths):
|
|
61
|
+
raise ValueError('An active markdown input is missing; rerun `citegraph convert`')
|
|
62
|
+
return paths
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def work_identity_json(self) -> Path:
|
|
66
|
+
return self.out_dir / "work_identity.json"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def author_identity_json(self) -> Path:
|
|
70
|
+
return self.out_dir / "author_identity.json"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def enrichment_provenance_json(self) -> Path:
|
|
74
|
+
return self.out_dir / "enrichment_provenance.json"
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def source_ids_json(self) -> Path:
|
|
78
|
+
return self.out_dir / "source_ids.json"
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def work_id_collisions_json(self) -> Path:
|
|
82
|
+
return self.out_dir / "work_id_collisions.json"
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def markdown_dir(self) -> Path:
|
|
86
|
+
return self.out_dir / "markdown"
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def metadata_dir(self) -> Path:
|
|
90
|
+
return self.out_dir / "metadata"
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def references_dir(self) -> Path:
|
|
94
|
+
return self.out_dir / "references"
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def enrichment_dir(self) -> Path:
|
|
98
|
+
return self.out_dir / "enrichment"
|
|
99
|
+
|
|
100
|
+
def enrichment_cache_count(self) -> int:
|
|
101
|
+
"""Number of per-reference enrichment cache files on disk.
|
|
102
|
+
|
|
103
|
+
Non-zero while ``enriched_works.csv`` is absent means the
|
|
104
|
+
enrich stage was interrupted before its final CSV write — the
|
|
105
|
+
authors stage warns about that state instead of silently
|
|
106
|
+
clustering without external ids.
|
|
107
|
+
"""
|
|
108
|
+
if not self.enrichment_dir.is_dir():
|
|
109
|
+
return 0
|
|
110
|
+
return sum(1 for _ in self.enrichment_dir.glob("*.json"))
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def enrichment_misses_csv(self) -> Path:
|
|
114
|
+
return self.out_dir / "enrichment_misses.csv"
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def enrichment_summary_json(self) -> Path:
|
|
118
|
+
return self.out_dir / "enrichment_summary.json"
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def graph_csv(self) -> Path:
|
|
122
|
+
return self.out_dir / "citation_graph.csv"
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def sources_csv(self) -> Path:
|
|
126
|
+
return self.out_dir / "sources.csv"
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def citations_raw_csv(self) -> Path:
|
|
130
|
+
return self.out_dir / "citations_raw.csv"
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def works_csv(self) -> Path:
|
|
134
|
+
return self.out_dir / "works.csv"
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def enriched_works_csv(self) -> Path:
|
|
138
|
+
return self.out_dir / "enriched_works.csv"
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def authors_csv(self) -> Path:
|
|
142
|
+
return self.out_dir / "authors.csv"
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def author_citations_csv(self) -> Path:
|
|
146
|
+
return self.out_dir / "author_citations.csv"
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def author_review_json(self) -> Path:
|
|
150
|
+
return self.out_dir / "author_review.json"
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def author_resolution_audit_json(self) -> Path:
|
|
154
|
+
return self.out_dir / "author_resolution_audit.json"
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def canonicalization_audit_json(self) -> Path:
|
|
158
|
+
return self.out_dir / "canonicalization_audit.json"
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def author_aliases_csv(self) -> Path:
|
|
162
|
+
return self.out_dir / "author_aliases.csv"
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def journal_aliases_csv(self) -> Path:
|
|
166
|
+
return self.out_dir / "journal_aliases.csv"
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def work_annotations_csv(self) -> Path:
|
|
170
|
+
return self.out_dir / "work_annotations.csv"
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def annotation_schema_csv(self) -> Path:
|
|
174
|
+
return self.out_dir / "annotation_schema.csv"
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def jel_codes_csv(self) -> Path:
|
|
178
|
+
return self.out_dir / "jel_codes.csv"
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def author_external_ids_csv(self) -> Path:
|
|
182
|
+
return self.out_dir / "author_external_ids.csv"
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def author_overrides_csv(self) -> Path:
|
|
186
|
+
return self.out_dir / "author_overrides.csv"
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def author_overrides_meta_json(self) -> Path:
|
|
190
|
+
return self.out_dir / "author_overrides_meta.json"
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def run_summary_json(self) -> Path:
|
|
194
|
+
return self.out_dir / "run_summary.json"
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def metadata_failures_jsonl(self) -> Path:
|
|
198
|
+
return self.out_dir / "metadata_failures.jsonl"
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def references_failures_jsonl(self) -> Path:
|
|
202
|
+
return self.out_dir / "references_failures.jsonl"
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def source_duplicates_json(self) -> Path:
|
|
206
|
+
return self.out_dir / "source_duplicates.json"
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def papers_no_references_json(self) -> Path:
|
|
210
|
+
return self.out_dir / "papers_no_references.json"
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def conversion_warnings_json(self) -> Path:
|
|
214
|
+
return self.out_dir / "conversion_warnings.json"
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def journal_alias_warnings_json(self) -> Path:
|
|
218
|
+
return self.out_dir / "journal_alias_warnings.json"
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def artifact_manifest_json(self) -> Path:
|
|
222
|
+
return self.out_dir / "artifact_manifest.json"
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def author_centrality_csv(self) -> Path:
|
|
226
|
+
return self.out_dir / "author_centrality.csv"
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def core_authors_xlsx(self) -> Path:
|
|
230
|
+
return self.out_dir / "core_authors.xlsx"
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def report_html(self) -> Path:
|
|
234
|
+
return self.out_dir / "report.html"
|
|
235
|
+
|
|
236
|
+
def ensure(self) -> None:
|
|
237
|
+
for d in (
|
|
238
|
+
self.out_dir,
|
|
239
|
+
self.markdown_dir,
|
|
240
|
+
self.metadata_dir,
|
|
241
|
+
self.references_dir,
|
|
242
|
+
self.enrichment_dir,
|
|
243
|
+
):
|
|
244
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def write_json(path: Path, data: dict | list) -> None:
|
|
248
|
+
"""Atomically replace JSON checkpoints, never leaving a partial document."""
|
|
249
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
|
|
251
|
+
try:
|
|
252
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
253
|
+
json.dump(data, stream, ensure_ascii=False, indent=2, allow_nan=False)
|
|
254
|
+
os.replace(temporary, path)
|
|
255
|
+
finally:
|
|
256
|
+
if os.path.exists(temporary):
|
|
257
|
+
os.unlink(temporary)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def write_csv(path: Path, frame: pd.DataFrame, *, index: bool = False) -> None:
|
|
261
|
+
"""Atomically replace a CSV checkpoint."""
|
|
262
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
|
|
264
|
+
os.close(fd)
|
|
265
|
+
try:
|
|
266
|
+
frame.to_csv(temporary, index=index)
|
|
267
|
+
os.replace(temporary, path)
|
|
268
|
+
finally:
|
|
269
|
+
if os.path.exists(temporary):
|
|
270
|
+
os.unlink(temporary)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def cache_input_fingerprint(path: Path) -> str:
|
|
274
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _cache_provenance(path: Path) -> dict[str, Any] | None:
|
|
278
|
+
sidecar = path.with_suffix(path.suffix + ".sha256")
|
|
279
|
+
if not sidecar.exists():
|
|
280
|
+
return None
|
|
281
|
+
try:
|
|
282
|
+
data = read_json(sidecar)
|
|
283
|
+
except (json.JSONDecodeError, UnicodeError, OSError):
|
|
284
|
+
return None
|
|
285
|
+
if not isinstance(data, dict):
|
|
286
|
+
return None
|
|
287
|
+
if data.get("version", 1) != 1:
|
|
288
|
+
return None
|
|
289
|
+
input_hash = data.get("input_sha256")
|
|
290
|
+
output_hash = data.get("output_sha256")
|
|
291
|
+
if not isinstance(input_hash, str) or not isinstance(output_hash, str):
|
|
292
|
+
return None
|
|
293
|
+
return data
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def cache_is_current(
|
|
297
|
+
path: Path, input_path: Path, *, recipe: dict[str, Any] | None = None
|
|
298
|
+
) -> bool:
|
|
299
|
+
"""Accept legacy caches; validate new caches by input and output hashes."""
|
|
300
|
+
if not path.exists():
|
|
301
|
+
return False
|
|
302
|
+
sidecar = path.with_suffix(path.suffix + ".sha256")
|
|
303
|
+
if not sidecar.exists():
|
|
304
|
+
return True
|
|
305
|
+
provenance = _cache_provenance(path)
|
|
306
|
+
if provenance is not None:
|
|
307
|
+
return (
|
|
308
|
+
provenance["input_sha256"] == cache_input_fingerprint(input_path)
|
|
309
|
+
and provenance["output_sha256"] == cache_input_fingerprint(path)
|
|
310
|
+
and (
|
|
311
|
+
recipe is None
|
|
312
|
+
or "recipe" not in provenance
|
|
313
|
+
or provenance["recipe"] == recipe
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
try:
|
|
317
|
+
return (
|
|
318
|
+
sidecar.read_text(encoding="ascii").strip()
|
|
319
|
+
== cache_input_fingerprint(input_path)
|
|
320
|
+
)
|
|
321
|
+
except (UnicodeError, OSError):
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def write_cache_fingerprint(
|
|
326
|
+
path: Path, input_path: Path, *, recipe: dict[str, Any] | None = None
|
|
327
|
+
) -> None:
|
|
328
|
+
provenance: dict[str, Any] = {
|
|
329
|
+
"version": 1,
|
|
330
|
+
"input_sha256": cache_input_fingerprint(input_path),
|
|
331
|
+
"output_sha256": cache_input_fingerprint(path),
|
|
332
|
+
}
|
|
333
|
+
if recipe is not None:
|
|
334
|
+
provenance["recipe"] = recipe
|
|
335
|
+
write_json(
|
|
336
|
+
path.with_suffix(path.suffix + ".sha256"),
|
|
337
|
+
provenance,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def read_json(path: Path) -> dict | list:
|
|
342
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def read_pydantic_cache(
|
|
346
|
+
path: Path,
|
|
347
|
+
input_path: Path,
|
|
348
|
+
schema: type[BaseModel],
|
|
349
|
+
*,
|
|
350
|
+
many: bool = False,
|
|
351
|
+
recipe: dict[str, Any] | None = None,
|
|
352
|
+
) -> BaseModel | list[BaseModel] | None:
|
|
353
|
+
"""Return a current, valid cache or ``None`` when it must be refreshed."""
|
|
354
|
+
def validated(cache_path: Path) -> tuple[dict | list, BaseModel | list[BaseModel]] | None:
|
|
355
|
+
try:
|
|
356
|
+
data = read_json(cache_path)
|
|
357
|
+
if many:
|
|
358
|
+
value = (
|
|
359
|
+
[schema.model_validate(item) for item in data]
|
|
360
|
+
if isinstance(data, list)
|
|
361
|
+
else None
|
|
362
|
+
)
|
|
363
|
+
else:
|
|
364
|
+
value = schema.model_validate(data)
|
|
365
|
+
return (data, value) if value is not None else None
|
|
366
|
+
except (json.JSONDecodeError, UnicodeError, OSError, TypeError, ValidationError):
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
if path.exists() and cache_is_current(path, input_path, recipe=recipe):
|
|
370
|
+
result = validated(path)
|
|
371
|
+
if result is not None:
|
|
372
|
+
if _cache_provenance(path) is None:
|
|
373
|
+
write_cache_fingerprint(path, input_path)
|
|
374
|
+
return result[1]
|
|
375
|
+
|
|
376
|
+
input_hash = cache_input_fingerprint(input_path)
|
|
377
|
+
# ponytail: this is O(n²) when every file misses; add a per-directory index
|
|
378
|
+
# if extraction caches grow beyond the current corpus scale.
|
|
379
|
+
for sibling in sorted(path.parent.glob("*.json")):
|
|
380
|
+
if sibling == path:
|
|
381
|
+
continue
|
|
382
|
+
provenance = _cache_provenance(sibling)
|
|
383
|
+
if (
|
|
384
|
+
provenance is None
|
|
385
|
+
or provenance["input_sha256"] != input_hash
|
|
386
|
+
or (recipe is not None and provenance.get("recipe") != recipe)
|
|
387
|
+
):
|
|
388
|
+
continue
|
|
389
|
+
try:
|
|
390
|
+
if provenance["output_sha256"] != cache_input_fingerprint(sibling):
|
|
391
|
+
continue
|
|
392
|
+
except OSError:
|
|
393
|
+
continue
|
|
394
|
+
result = validated(sibling)
|
|
395
|
+
if result is None:
|
|
396
|
+
continue
|
|
397
|
+
write_json(path, result[0])
|
|
398
|
+
write_cache_fingerprint(path, input_path, recipe=provenance.get('recipe'))
|
|
399
|
+
return result[1]
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def write_pydantic(path: Path, obj: BaseModel) -> None:
|
|
404
|
+
write_json(path, obj.model_dump())
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def write_pydantic_list(path: Path, objs: Iterable[BaseModel]) -> None:
|
|
408
|
+
write_json(path, [o.model_dump() for o in objs])
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def serialize_structured(value: Any) -> str:
|
|
412
|
+
"""Serialize structured CSV cell values with JSON syntax."""
|
|
413
|
+
return json.dumps(value, ensure_ascii=False)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _parse_structured(value: object) -> object:
|
|
417
|
+
if isinstance(value, (list, dict)):
|
|
418
|
+
return value
|
|
419
|
+
if not isinstance(value, str):
|
|
420
|
+
return None
|
|
421
|
+
s = value.strip()
|
|
422
|
+
if not s:
|
|
423
|
+
return None
|
|
424
|
+
if s.startswith("[") or s.startswith("{"):
|
|
425
|
+
try:
|
|
426
|
+
return json.loads(s)
|
|
427
|
+
except json.JSONDecodeError:
|
|
428
|
+
try:
|
|
429
|
+
return ast.literal_eval(s)
|
|
430
|
+
except (ValueError, SyntaxError):
|
|
431
|
+
return None
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def parse_authors_list(value: object) -> list[str]:
|
|
436
|
+
"""Parse an Authors_List CSV cell or in-memory value into author strings."""
|
|
437
|
+
parsed = _parse_structured(value)
|
|
438
|
+
if isinstance(parsed, list):
|
|
439
|
+
return [str(item) for item in parsed if item]
|
|
440
|
+
if isinstance(value, str):
|
|
441
|
+
s = value.strip()
|
|
442
|
+
if not s:
|
|
443
|
+
return []
|
|
444
|
+
if ";" in s:
|
|
445
|
+
return [part.strip() for part in s.split(";") if part.strip()]
|
|
446
|
+
return [s]
|
|
447
|
+
return []
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def parse_openalex_authors(value: object) -> list[dict] | None:
|
|
451
|
+
"""Parse the OpenAlex_Authors structured CSV cell."""
|
|
452
|
+
parsed = _parse_structured(value)
|
|
453
|
+
if not isinstance(parsed, list):
|
|
454
|
+
return None
|
|
455
|
+
out = []
|
|
456
|
+
for item in parsed:
|
|
457
|
+
if isinstance(item, dict):
|
|
458
|
+
out.append(item)
|
|
459
|
+
return out
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def require_columns(df: Any, columns: Iterable[str], *, artifact: str) -> None:
|
|
463
|
+
"""Raise a clear error when a DataFrame-like artifact lacks required columns."""
|
|
464
|
+
available = set(getattr(df, "columns", []))
|
|
465
|
+
missing = [column for column in columns if column not in available]
|
|
466
|
+
if missing:
|
|
467
|
+
suffix = "s" if len(missing) > 1 else ""
|
|
468
|
+
raise ValueError(
|
|
469
|
+
f"{artifact} is missing required column{suffix}: {', '.join(missing)}"
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
SOURCE_COLUMNS = ["id", "source_file", "Title", "Authors", "Authors_List", "Journal", "Year"]
|
|
474
|
+
CITATION_COLUMNS = ["Title", "Authors", "Authors_List", "Journal", "Year", "citing_id"]
|
|
475
|
+
WORK_COLUMNS = ["id", "ring", "source_file", "Title", "Authors", "Authors_List", "Journal", "Year"]
|
|
476
|
+
AUTHOR_COLUMNS = ["id", "display_name", "surname", "surname_norm", "canonical_given", "initials",
|
|
477
|
+
"openalex_id", "orcid", "n_works", "n_core_works", "n_citations_received",
|
|
478
|
+
"n_distinct_citing_works"]
|
|
479
|
+
AUTHOR_CITATION_COLUMNS = ["author_id", "record_id", "position", "raw_author"]
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def read_stage_csv(path: Path, *, columns: list[str], index_col: str | None = None) -> pd.DataFrame:
|
|
483
|
+
"""Read a checkpoint, accepting only truly blank legacy files as empty."""
|
|
484
|
+
try:
|
|
485
|
+
frame = pd.read_csv(path)
|
|
486
|
+
except pd.errors.EmptyDataError:
|
|
487
|
+
frame = pd.DataFrame(columns=columns)
|
|
488
|
+
# Bibliographic APIs support either author representation, including legacy CSVs.
|
|
489
|
+
required = [c for c in columns if c not in ("Authors", "Authors_List", "Journal")]
|
|
490
|
+
if "ring" in columns:
|
|
491
|
+
required = [c for c in required if c not in ("ring", "source_file")]
|
|
492
|
+
require_columns(frame, required, artifact=path.stem)
|
|
493
|
+
if "Authors" in columns and not {"Authors", "Authors_List"} & set(frame.columns):
|
|
494
|
+
raise ValueError(f"{path.name} is missing required column: Authors or Authors_List")
|
|
495
|
+
return frame.set_index(index_col) if index_col else frame
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def fingerprint(value: Any) -> str:
|
|
499
|
+
"""Content digest for provenance only, never for entity ID generation."""
|
|
500
|
+
def clean(item: Any) -> Any:
|
|
501
|
+
if isinstance(item, dict):
|
|
502
|
+
return {str(k): clean(v) for k, v in item.items()}
|
|
503
|
+
if isinstance(item, (list, tuple)):
|
|
504
|
+
return [clean(v) for v in item]
|
|
505
|
+
if isinstance(item, str):
|
|
506
|
+
if not item:
|
|
507
|
+
return None
|
|
508
|
+
structured = _parse_structured(item)
|
|
509
|
+
return clean(structured) if structured is not None else item
|
|
510
|
+
if item is None or pd.isna(item):
|
|
511
|
+
return None
|
|
512
|
+
if hasattr(item, "item"):
|
|
513
|
+
item = item.item()
|
|
514
|
+
if isinstance(item, float) and item.is_integer():
|
|
515
|
+
return int(item)
|
|
516
|
+
return item
|
|
517
|
+
payload = json.dumps(clean(value), sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
518
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def frame_fingerprint(frame: pd.DataFrame) -> str:
|
|
522
|
+
rows = frame.reset_index() if frame.index.name and frame.index.name not in frame.columns else frame
|
|
523
|
+
return fingerprint({"columns": list(rows.columns), "records": rows.to_dict("records")})
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def artifact_fingerprint(path: Path) -> str | None:
|
|
527
|
+
"""Snapshot a checkpoint; distinguish absence from a present empty artifact."""
|
|
528
|
+
if not path.exists():
|
|
529
|
+
return None
|
|
530
|
+
if path.suffix == ".csv":
|
|
531
|
+
try:
|
|
532
|
+
return frame_fingerprint(pd.read_csv(path))
|
|
533
|
+
except pd.errors.EmptyDataError:
|
|
534
|
+
return frame_fingerprint(pd.DataFrame())
|
|
535
|
+
except pd.errors.ParserError:
|
|
536
|
+
# Hand-curated CSVs may contain comment lines with commas. Hash
|
|
537
|
+
# the bytes rather than making those comments invalid to pandas.
|
|
538
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
539
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# Fixed names also constrain report reads: audit contents never select arbitrary paths.
|
|
543
|
+
WORK_AUDIT_INPUTS = {"sources.csv", "citations_raw.csv", "work_identity.json"}
|
|
544
|
+
WORK_AUDIT_OUTPUTS = {"works.csv", "citation_graph.csv"}
|
|
545
|
+
AUTHOR_AUDIT_INPUTS = {
|
|
546
|
+
"works.csv", "citation_graph.csv", "enriched_works.csv", "enrichment_provenance.json",
|
|
547
|
+
"source_ids.json", "work_id_collisions.json", "author_aliases.csv", "journal_aliases.csv", "author_external_ids.csv", "author_overrides.csv", "author_overrides_meta.json",
|
|
548
|
+
"work_identity.json", "author_identity.json",
|
|
549
|
+
}
|
|
550
|
+
AUTHOR_AUDIT_OUTPUTS = {"authors.csv", "author_citations.csv", "author_review.json"}
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def unverified_collision_ids(layout: OutLayout) -> set[str]:
|
|
554
|
+
"""IDs whose legacy identity evidence is unsafe, including historical collisions."""
|
|
555
|
+
ids: set[str] = set()
|
|
556
|
+
for path in (layout.source_ids_json, layout.work_id_collisions_json):
|
|
557
|
+
if path.exists():
|
|
558
|
+
ids.update(read_json(path).get("collision_ids", []))
|
|
559
|
+
return ids
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def metadata_fingerprint(record: dict) -> str:
|
|
563
|
+
authors = parse_authors_list(record.get("Authors_List"))
|
|
564
|
+
return fingerprint({"Title": record.get("Title"), "Authors_List": authors,
|
|
565
|
+
"Authors": ", ".join(authors) if authors else record.get("Authors"),
|
|
566
|
+
"Journal": record.get("Journal"), "Year": record.get("Year")})
|