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/reports.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Report and sidecar artifact helpers for pipeline runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import asdict, is_dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from citegraph.dedup import DedupConfig
|
|
14
|
+
from citegraph.io import OutLayout, read_json, write_json
|
|
15
|
+
from citegraph.pdf_to_markdown import _has_unmappable_glyphs, _is_image_only
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
ARTIFACT_SCHEMA_VERSION = 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _package_version() -> str:
|
|
23
|
+
try:
|
|
24
|
+
return version("citegraph")
|
|
25
|
+
except PackageNotFoundError:
|
|
26
|
+
return "0.2.0"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_artifact_manifest(
|
|
30
|
+
layout: OutLayout,
|
|
31
|
+
*,
|
|
32
|
+
stage: str,
|
|
33
|
+
artifacts: dict[str, Path],
|
|
34
|
+
config: dict[str, Any] | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Write a minimal manifest describing generated artifacts.
|
|
37
|
+
|
|
38
|
+
The manifest is intentionally small: it gives future versions enough
|
|
39
|
+
information to explain stale cache behavior without introducing a
|
|
40
|
+
migration system before the package needs one.
|
|
41
|
+
"""
|
|
42
|
+
manifest = {
|
|
43
|
+
"schema_version": ARTIFACT_SCHEMA_VERSION,
|
|
44
|
+
"package_version": _package_version(),
|
|
45
|
+
"generated_at": datetime.now(UTC).isoformat(),
|
|
46
|
+
"stage": stage,
|
|
47
|
+
"artifacts": {
|
|
48
|
+
name: str(path.relative_to(layout.out_dir))
|
|
49
|
+
if path.is_relative_to(layout.out_dir)
|
|
50
|
+
else str(path)
|
|
51
|
+
for name, path in artifacts.items()
|
|
52
|
+
},
|
|
53
|
+
"config": config or {},
|
|
54
|
+
}
|
|
55
|
+
write_json(layout.artifact_manifest_json, manifest)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write_failures(path: Path, failures: list[Any]) -> None:
|
|
59
|
+
"""Persist failures as JSONL, or remove the file when none occurred."""
|
|
60
|
+
if not failures:
|
|
61
|
+
if path.exists():
|
|
62
|
+
path.unlink()
|
|
63
|
+
return
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
with path.open("w", encoding="utf-8") as f:
|
|
66
|
+
for fail in failures:
|
|
67
|
+
payload = asdict(fail) if is_dataclass(fail) else dict(fail)
|
|
68
|
+
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def count_failures(path: Path) -> int:
|
|
72
|
+
"""Count entries in a failures.jsonl file; 0 if the file doesn't exist."""
|
|
73
|
+
if not path.exists():
|
|
74
|
+
return 0
|
|
75
|
+
with path.open("r", encoding="utf-8") as f:
|
|
76
|
+
return sum(1 for line in f if line.strip())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def detect_source_duplicates(records: list[dict], cfg: DedupConfig) -> list[dict]:
|
|
80
|
+
"""Find records that are duplicate conversions of the same underlying paper."""
|
|
81
|
+
from citegraph.dedup import compare_papers
|
|
82
|
+
|
|
83
|
+
canonical_indices: list[int] = []
|
|
84
|
+
duplicate_of: dict[int, int] = {}
|
|
85
|
+
|
|
86
|
+
for i, rec in enumerate(records):
|
|
87
|
+
matched = False
|
|
88
|
+
for canon_idx in canonical_indices:
|
|
89
|
+
if compare_papers(rec, records[canon_idx], cfg):
|
|
90
|
+
duplicate_of[i] = canon_idx
|
|
91
|
+
matched = True
|
|
92
|
+
break
|
|
93
|
+
if not matched:
|
|
94
|
+
canonical_indices.append(i)
|
|
95
|
+
|
|
96
|
+
canon_to_dupes: dict[int, list[int]] = {}
|
|
97
|
+
for dup_idx, canon_idx in duplicate_of.items():
|
|
98
|
+
canon_to_dupes.setdefault(canon_idx, []).append(dup_idx)
|
|
99
|
+
|
|
100
|
+
groups = []
|
|
101
|
+
for canon_idx, dup_indices in canon_to_dupes.items():
|
|
102
|
+
canon = records[canon_idx]
|
|
103
|
+
groups.append(
|
|
104
|
+
{
|
|
105
|
+
"canonical_source_file": canon["source_file"],
|
|
106
|
+
"canonical_paper_id": canon["id"],
|
|
107
|
+
"title": canon.get("Title", ""),
|
|
108
|
+
"duplicate_source_files": [records[i]["source_file"] for i in dup_indices],
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
return groups
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def write_no_references(path: Path, entries: list[dict]) -> None:
|
|
115
|
+
"""Persist papers that returned zero references; remove the file when none exist."""
|
|
116
|
+
if not entries:
|
|
117
|
+
if path.exists():
|
|
118
|
+
path.unlink()
|
|
119
|
+
return
|
|
120
|
+
write_json(path, entries)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def write_source_duplicates(path: Path, groups: list[dict]) -> None:
|
|
124
|
+
"""Persist duplicate groups as JSON, or remove the file when none exist."""
|
|
125
|
+
if not groups:
|
|
126
|
+
if path.exists():
|
|
127
|
+
path.unlink()
|
|
128
|
+
return
|
|
129
|
+
write_json(path, groups)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def count_source_duplicates(path: Path) -> int:
|
|
133
|
+
"""Total number of duplicate markdown files; 0 if the file doesn't exist."""
|
|
134
|
+
if not path.exists():
|
|
135
|
+
return 0
|
|
136
|
+
data = read_json(path)
|
|
137
|
+
return sum(len(g.get("duplicate_source_files", [])) for g in data)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def count_no_references(path: Path) -> int:
|
|
141
|
+
if not path.exists():
|
|
142
|
+
return 0
|
|
143
|
+
return len(read_json(path))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def count_journal_alias_warnings(path: Path) -> int:
|
|
147
|
+
"""Curated journal aliases that folded nothing; 0 if the file doesn't exist."""
|
|
148
|
+
if not path.exists():
|
|
149
|
+
return 0
|
|
150
|
+
return len(read_json(path).get("unused_aliases", []))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def check_conversion_quality(
|
|
154
|
+
paths: list[Path], layout: OutLayout, *, ocr_attempted: bool = False
|
|
155
|
+
) -> None:
|
|
156
|
+
"""After conversion, flag markdown that is image-only or glyph-corrupted."""
|
|
157
|
+
reason = (
|
|
158
|
+
"image-only markdown even after OCR (manual review needed)"
|
|
159
|
+
if ocr_attempted
|
|
160
|
+
else "image-only markdown (possibly a scanned PDF)"
|
|
161
|
+
)
|
|
162
|
+
glyph_reason = (
|
|
163
|
+
"unmappable glyphs even after OCR (manual review needed)"
|
|
164
|
+
if ocr_attempted
|
|
165
|
+
else "unmappable glyphs (font has no ToUnicode map); re-run this PDF with OCR"
|
|
166
|
+
)
|
|
167
|
+
warnings = []
|
|
168
|
+
for p in paths:
|
|
169
|
+
if _is_image_only(p):
|
|
170
|
+
warnings.append({"source_file": p.name, "reason": reason})
|
|
171
|
+
elif _has_unmappable_glyphs(p):
|
|
172
|
+
warnings.append({"source_file": p.name, "reason": glyph_reason})
|
|
173
|
+
write_conversion_warnings(layout.conversion_warnings_json, warnings)
|
|
174
|
+
if warnings:
|
|
175
|
+
hint = (
|
|
176
|
+
"OCR did not help; inspect the source PDFs and consider manual transcription."
|
|
177
|
+
if ocr_attempted
|
|
178
|
+
else "re-run with ocr=True / --ocr (or ocr='auto' / --ocr-auto) for better results."
|
|
179
|
+
)
|
|
180
|
+
logger.warning(
|
|
181
|
+
"%d markdown file(s) converted poorly; %s See %s",
|
|
182
|
+
len(warnings),
|
|
183
|
+
hint,
|
|
184
|
+
layout.conversion_warnings_json,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def write_conversion_warnings(path: Path, warnings: list[dict]) -> None:
|
|
189
|
+
if not warnings:
|
|
190
|
+
if path.exists():
|
|
191
|
+
path.unlink()
|
|
192
|
+
return
|
|
193
|
+
write_json(path, warnings)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def count_conversion_warnings(path: Path) -> int:
|
|
197
|
+
if not path.exists():
|
|
198
|
+
return 0
|
|
199
|
+
return len(read_json(path))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def write_author_review(path: Path, review: list[dict]) -> None:
|
|
203
|
+
"""Persist flagged author clusters, or remove the file when there are none."""
|
|
204
|
+
if not review:
|
|
205
|
+
if path.exists():
|
|
206
|
+
path.unlink()
|
|
207
|
+
return
|
|
208
|
+
write_json(path, review)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def count_author_review(path: Path) -> int:
|
|
212
|
+
if not path.exists():
|
|
213
|
+
return 0
|
|
214
|
+
return len(read_json(path))
|
citegraph/schemas.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Pydantic schemas used both as I/O DTOs and Gemini response schemas.
|
|
2
|
+
|
|
3
|
+
``Authors`` is a *derived* property (``", ".join(Authors_List)``), not part
|
|
4
|
+
of the LLM contract — only ``Authors_List`` is. The LLM used to be asked
|
|
5
|
+
for both, which (a) wasted output tokens and (b) created a real failure
|
|
6
|
+
mode where the two could disagree. Both schemas override ``model_dump`` to
|
|
7
|
+
include the derived ``Authors`` so CSV/cache writers see the column.
|
|
8
|
+
|
|
9
|
+
Old cache files that contain an ``Authors`` field still load cleanly: the
|
|
10
|
+
``extra="ignore"`` config drops the unknown key during validation, and the
|
|
11
|
+
property recomputes from ``Authors_List`` on access.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PaperMetadata(BaseModel):
|
|
24
|
+
"""Bibliographic metadata extracted from a single source paper.
|
|
25
|
+
|
|
26
|
+
Used both as the output of :mod:`citegraph.extract_metadata` and as the
|
|
27
|
+
``response_schema`` for Gemini's structured output.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(extra="ignore")
|
|
31
|
+
|
|
32
|
+
Title: str = Field(description="The full title of the paper.")
|
|
33
|
+
Authors_List: list[str] = Field(description="List of every author name.")
|
|
34
|
+
Journal: str = Field(description="Journal or venue where the paper was published.")
|
|
35
|
+
Year: int = Field(description="Publication year as an integer.")
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def Authors(self) -> str:
|
|
39
|
+
return ", ".join(self.Authors_List)
|
|
40
|
+
|
|
41
|
+
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
|
42
|
+
data = super().model_dump(**kwargs)
|
|
43
|
+
data["Authors"] = self.Authors
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Reference(BaseModel):
|
|
48
|
+
"""A single bibliographic reference extracted from a paper's bibliography."""
|
|
49
|
+
|
|
50
|
+
model_config = ConfigDict(extra="ignore")
|
|
51
|
+
|
|
52
|
+
Title: str
|
|
53
|
+
Authors_List: list[str]
|
|
54
|
+
Journal: str
|
|
55
|
+
Year: int
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def Authors(self) -> str:
|
|
59
|
+
return ", ".join(self.Authors_List)
|
|
60
|
+
|
|
61
|
+
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
|
62
|
+
data = super().model_dump(**kwargs)
|
|
63
|
+
data["Authors"] = self.Authors
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CitationLink(BaseModel):
|
|
68
|
+
"""An edge from a citing paper to a (deduplicated) cited paper."""
|
|
69
|
+
|
|
70
|
+
citing_id: str
|
|
71
|
+
cited_id: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class PipelineResult:
|
|
76
|
+
"""Container returned by :meth:`citegraph.Pipeline.run`.
|
|
77
|
+
|
|
78
|
+
Pandas DataFrames mirroring the on-disk CSVs:
|
|
79
|
+
|
|
80
|
+
- ``works`` : one row per canonical work, indexed by ``id``; the
|
|
81
|
+
``ring`` column marks provenance (0 = the user's core PDFs) and
|
|
82
|
+
``source_file`` is non-empty for processed PDFs.
|
|
83
|
+
- ``graph`` : edges ``(citing_id, cited_id)`` between works.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
works: pd.DataFrame
|
|
87
|
+
graph: pd.DataFrame
|
|
88
|
+
authors: pd.DataFrame | None = None
|
|
89
|
+
author_citations: pd.DataFrame | None = None
|