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/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")})