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/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""citegraph: build a deduplicated citation graph from a folder of PDFs.
|
|
2
|
+
|
|
3
|
+
See :class:`citegraph.Pipeline` for the high-level entry point::
|
|
4
|
+
|
|
5
|
+
from citegraph import Pipeline
|
|
6
|
+
|
|
7
|
+
pipe = Pipeline(pdf_dir="./pdfs", out_dir="./out")
|
|
8
|
+
result = pipe.run()
|
|
9
|
+
result.works, result.graph
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from citegraph.authors import (
|
|
15
|
+
AuthorClusterConfig,
|
|
16
|
+
ParsedAuthor,
|
|
17
|
+
normalize_authors,
|
|
18
|
+
parse_author,
|
|
19
|
+
)
|
|
20
|
+
from citegraph.cost_estimation import ExtractionEstimate
|
|
21
|
+
from citegraph.graph import CitationGraph
|
|
22
|
+
from citegraph.pipeline import Pipeline
|
|
23
|
+
from citegraph.schemas import (
|
|
24
|
+
CitationLink,
|
|
25
|
+
PaperMetadata,
|
|
26
|
+
PipelineResult,
|
|
27
|
+
Reference,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Pipeline",
|
|
32
|
+
"CitationGraph",
|
|
33
|
+
"ExtractionEstimate",
|
|
34
|
+
"PipelineResult",
|
|
35
|
+
"PaperMetadata",
|
|
36
|
+
"Reference",
|
|
37
|
+
"CitationLink",
|
|
38
|
+
"AuthorClusterConfig",
|
|
39
|
+
"ParsedAuthor",
|
|
40
|
+
"normalize_authors",
|
|
41
|
+
"parse_author",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
__version__ = "0.2.0"
|
citegraph/_progress.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Internal helper: iterate a list with an optional rich progress bar.
|
|
2
|
+
|
|
3
|
+
Stage loops in :mod:`citegraph.pipeline` and the PDF -> markdown loop in
|
|
4
|
+
:mod:`citegraph.pdf_to_markdown` all want the same UX (spinner, current
|
|
5
|
+
item label, count, elapsed, ETA) over a list whose body is per-item work.
|
|
6
|
+
This module exists so that affordance lives in one place.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Iterator
|
|
12
|
+
from typing import TypeVar
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def iter_with_progress(
|
|
18
|
+
items: list[T],
|
|
19
|
+
*,
|
|
20
|
+
show_progress: bool,
|
|
21
|
+
description: str,
|
|
22
|
+
item_label: Callable[[T], str] | None = None,
|
|
23
|
+
) -> Iterator[T]:
|
|
24
|
+
"""Yield ``items`` one at a time, optionally with a rich progress bar.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
items:
|
|
29
|
+
Sequence to iterate. ``len(items)`` sets the bar total.
|
|
30
|
+
show_progress:
|
|
31
|
+
When ``False`` (or ``items`` is empty), yields plainly with no
|
|
32
|
+
bar — callers don't need to branch on the flag themselves.
|
|
33
|
+
description:
|
|
34
|
+
Initial bar description, shown until the first item starts.
|
|
35
|
+
item_label:
|
|
36
|
+
Optional callable producing a per-item description (e.g. the
|
|
37
|
+
current filename). Long labels are truncated to keep the bar
|
|
38
|
+
layout from jittering.
|
|
39
|
+
"""
|
|
40
|
+
if not show_progress or not items:
|
|
41
|
+
yield from items
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
# Imported lazily so callers that pass ``show_progress=False`` don't
|
|
45
|
+
# pay the import cost — keeps test runs and headless CLI use cheap.
|
|
46
|
+
from rich.progress import (
|
|
47
|
+
BarColumn,
|
|
48
|
+
MofNCompleteColumn,
|
|
49
|
+
Progress,
|
|
50
|
+
SpinnerColumn,
|
|
51
|
+
TextColumn,
|
|
52
|
+
TimeElapsedColumn,
|
|
53
|
+
TimeRemainingColumn,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
with Progress(
|
|
57
|
+
SpinnerColumn(),
|
|
58
|
+
TextColumn("[progress.description]{task.description}"),
|
|
59
|
+
BarColumn(),
|
|
60
|
+
MofNCompleteColumn(),
|
|
61
|
+
TimeElapsedColumn(),
|
|
62
|
+
TimeRemainingColumn(),
|
|
63
|
+
transient=False,
|
|
64
|
+
) as progress:
|
|
65
|
+
task = progress.add_task(description, total=len(items))
|
|
66
|
+
for item in items:
|
|
67
|
+
if item_label is not None:
|
|
68
|
+
label = item_label(item)
|
|
69
|
+
if len(label) > 50:
|
|
70
|
+
label = label[:47] + "..."
|
|
71
|
+
progress.update(task, description=label)
|
|
72
|
+
yield item
|
|
73
|
+
progress.advance(task)
|
|
74
|
+
progress.update(task, description="Done")
|
citegraph/annotations.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Hand-curated facts about works, joined onto the pipeline's output.
|
|
2
|
+
|
|
3
|
+
Some things about a paper are never in the paper: the theme a reviewer
|
|
4
|
+
assigned it, whether it counts as the design the manuscript is about. They
|
|
5
|
+
are read off the PDF by a human — or by an agent — and recorded by hand.
|
|
6
|
+
|
|
7
|
+
They cannot live in ``works.csv``, which stage 4 regenerates from scratch;
|
|
8
|
+
a column typed in there survives exactly until the next ``citegraph dedup``.
|
|
9
|
+
So annotations live beside it in ``work_annotations.csv``, keyed on the work
|
|
10
|
+
``id``, and are joined at read time. This is the arrangement
|
|
11
|
+
``journal_aliases.csv`` and ``author_aliases.csv`` already use: lenient read,
|
|
12
|
+
strict validation, and a loud failure when a curated row no longer matches
|
|
13
|
+
anything.
|
|
14
|
+
|
|
15
|
+
``annotation_schema.csv`` (``column,type,allowed,description``) declares what
|
|
16
|
+
may be recorded. Declared columns are validated on load — an agent writing
|
|
17
|
+
``Maybe`` into an enum fails immediately, naming the work. Undeclared columns
|
|
18
|
+
are carried untouched, so a reviewer can add a question mid-review without a
|
|
19
|
+
code change. The schema is as much the annotator's instructions as it is a
|
|
20
|
+
constraint, which is why the description column is not decoration.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import csv
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import pandas as pd
|
|
31
|
+
|
|
32
|
+
from citegraph.io import OutLayout, read_json
|
|
33
|
+
|
|
34
|
+
SCHEMA_COLUMNS = ["column", "type", "allowed", "description"]
|
|
35
|
+
COLUMN_TYPES = ("enum", "text")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class AnnotationColumn:
|
|
40
|
+
"""One declared annotation column."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
type: str
|
|
44
|
+
allowed: tuple[str, ...]
|
|
45
|
+
description: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_annotation_schema(path: Path | str) -> dict[str, AnnotationColumn]:
|
|
49
|
+
"""Read ``annotation_schema.csv``; an absent file declares nothing."""
|
|
50
|
+
path = Path(path)
|
|
51
|
+
if not path.exists():
|
|
52
|
+
return {}
|
|
53
|
+
with path.open(encoding="utf-8", newline="") as stream:
|
|
54
|
+
rows = list(csv.DictReader(stream))
|
|
55
|
+
schema: dict[str, AnnotationColumn] = {}
|
|
56
|
+
for number, row in enumerate(rows, 2):
|
|
57
|
+
name = (row.get("column") or "").strip()
|
|
58
|
+
kind = (row.get("type") or "").strip()
|
|
59
|
+
allowed = tuple(
|
|
60
|
+
value.strip() for value in (row.get("allowed") or "").split("|") if value.strip()
|
|
61
|
+
)
|
|
62
|
+
if not name:
|
|
63
|
+
raise ValueError(f"Annotation schema row {number}: column name is empty")
|
|
64
|
+
if kind not in COLUMN_TYPES:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Annotation schema row {number}: unknown type {kind!r} for {name!r}; "
|
|
67
|
+
f"use one of {', '.join(COLUMN_TYPES)}"
|
|
68
|
+
)
|
|
69
|
+
if kind == "enum" and not allowed:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"Annotation schema row {number}: enum column {name!r} declares no "
|
|
72
|
+
"allowed values; list them separated by '|'"
|
|
73
|
+
)
|
|
74
|
+
if name in schema:
|
|
75
|
+
raise ValueError(f"Annotation schema declares {name!r} twice")
|
|
76
|
+
schema[name] = AnnotationColumn(
|
|
77
|
+
name=name,
|
|
78
|
+
type=kind,
|
|
79
|
+
allowed=allowed,
|
|
80
|
+
description=(row.get("description") or "").strip(),
|
|
81
|
+
)
|
|
82
|
+
return schema
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _redirects(path: Path) -> dict[str, str]:
|
|
86
|
+
"""Work-id redirects recorded by the identity registry beside the file."""
|
|
87
|
+
registry = OutLayout(path.parent).work_identity_json
|
|
88
|
+
return read_json(registry).get("redirects", {}) if registry.exists() else {}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _empty() -> pd.DataFrame:
|
|
92
|
+
return pd.DataFrame(index=pd.Index([], name="id"))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_annotations(
|
|
96
|
+
path: Path | str,
|
|
97
|
+
*,
|
|
98
|
+
works: pd.DataFrame,
|
|
99
|
+
schema_path: Path | str | None = None,
|
|
100
|
+
) -> pd.DataFrame:
|
|
101
|
+
"""Read ``work_annotations.csv`` as a frame ready to join onto ``works``.
|
|
102
|
+
|
|
103
|
+
Rows are keyed on the persistent work ``id`` and resolved through the
|
|
104
|
+
identity registry's redirects, so an annotation written before a merge
|
|
105
|
+
still reaches the work it became. An id that names no work raises rather
|
|
106
|
+
than being dropped: a curated row that silently applies to nothing is
|
|
107
|
+
worse than no row at all.
|
|
108
|
+
|
|
109
|
+
Columns that ``works`` already has (``Title``, ``Year``, …) are *context*
|
|
110
|
+
— they are written into the file so a human can read it, and are dropped
|
|
111
|
+
here rather than joined back over the pipeline's own values.
|
|
112
|
+
"""
|
|
113
|
+
from citegraph.identity import resolve_redirect
|
|
114
|
+
|
|
115
|
+
path = Path(path)
|
|
116
|
+
schema = load_annotation_schema(schema_path) if schema_path is not None else {}
|
|
117
|
+
if not path.exists():
|
|
118
|
+
return _empty()
|
|
119
|
+
|
|
120
|
+
with path.open(encoding="utf-8", newline="") as stream:
|
|
121
|
+
reader = csv.DictReader(stream)
|
|
122
|
+
fieldnames = reader.fieldnames or []
|
|
123
|
+
rows = list(reader)
|
|
124
|
+
if "id" not in fieldnames:
|
|
125
|
+
raise ValueError(f"{path} must have an 'id' column; got {fieldnames}")
|
|
126
|
+
|
|
127
|
+
redirects = _redirects(path)
|
|
128
|
+
known = set(works.index)
|
|
129
|
+
records: dict[str, dict[str, object]] = {}
|
|
130
|
+
for number, row in enumerate(rows, 2):
|
|
131
|
+
raw_id = (row.get("id") or "").strip()
|
|
132
|
+
if not raw_id:
|
|
133
|
+
raise ValueError(f"{path} row {number}: empty work id")
|
|
134
|
+
work_id = resolve_redirect(raw_id, redirects)
|
|
135
|
+
if work_id not in known:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"{path} row {number}: unknown work id {work_id!r}. Re-run the "
|
|
138
|
+
"import, or delete the row if the work is gone."
|
|
139
|
+
)
|
|
140
|
+
if work_id in records:
|
|
141
|
+
raise ValueError(f"{path} row {number}: work {work_id!r} is annotated twice")
|
|
142
|
+
records[work_id] = {
|
|
143
|
+
key: (value.strip() or None)
|
|
144
|
+
for key, value in row.items()
|
|
145
|
+
if key is not None and key != "id" and isinstance(value, str)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Context columns are readable padding for the editor, not annotations.
|
|
149
|
+
columns = [
|
|
150
|
+
name
|
|
151
|
+
for name in fieldnames
|
|
152
|
+
if name != "id" and (name in schema or name not in works.columns)
|
|
153
|
+
]
|
|
154
|
+
for name in schema:
|
|
155
|
+
if name not in columns:
|
|
156
|
+
columns.append(name)
|
|
157
|
+
|
|
158
|
+
frame = pd.DataFrame(
|
|
159
|
+
[[records[work_id].get(name) for name in columns] for work_id in records],
|
|
160
|
+
index=pd.Index(list(records), name="id"),
|
|
161
|
+
columns=columns,
|
|
162
|
+
dtype=object,
|
|
163
|
+
)
|
|
164
|
+
_validate_values(frame, schema, path)
|
|
165
|
+
return frame
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _validate_values(
|
|
169
|
+
frame: pd.DataFrame, schema: Mapping[str, AnnotationColumn], path: Path
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Reject any value a declared enum column does not allow."""
|
|
172
|
+
for name, column in schema.items():
|
|
173
|
+
if column.type != "enum" or name not in frame.columns:
|
|
174
|
+
continue
|
|
175
|
+
for work_id, value in frame[name].items():
|
|
176
|
+
if value is None or pd.isna(value):
|
|
177
|
+
continue
|
|
178
|
+
if value not in column.allowed:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"{path}: work {work_id!r} has {name}={value!r}, which is not "
|
|
181
|
+
f"one of {', '.join(column.allowed)}. Fix the value, or add it "
|
|
182
|
+
"to annotation_schema.csv."
|
|
183
|
+
)
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""One spreadsheet holding everything the corpus knows about its own authors.
|
|
2
|
+
|
|
3
|
+
``authors.csv`` describes every author in the corpus — thousands of them,
|
|
4
|
+
most encountered once in somebody's bibliography. The question a manuscript
|
|
5
|
+
actually asks is narrower: *who wrote my papers, and what do we know about
|
|
6
|
+
them?* That answer is spread over half a dozen artifacts, and often
|
|
7
|
+
reaches outside ``out_dir`` entirely, into hand-collected career metadata.
|
|
8
|
+
|
|
9
|
+
This module assembles it into a single workbook keyed on ``author_id``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from citegraph.io import OutLayout, read_json
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ExtraSheet:
|
|
24
|
+
"""A table from outside ``out_dir``, filtered to the core authors."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
frame: pd.DataFrame
|
|
28
|
+
description: str = ""
|
|
29
|
+
master_columns: tuple[str, ...] = ()
|
|
30
|
+
"""Columns lifted onto the ``Authors`` sheet; requires one row per author."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_GENERATED_SHEETS = ("README", "Authors", "Works", "Review_Flags")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validate_extra_sheet_names(extra_sheets: tuple[ExtraSheet, ...]) -> None:
|
|
37
|
+
used = {name.casefold(): name for name in _GENERATED_SHEETS}
|
|
38
|
+
for sheet in extra_sheets:
|
|
39
|
+
folded = sheet.name.casefold()
|
|
40
|
+
if folded in used:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"Extra sheet {sheet.name!r} conflicts with workbook sheet {used[folded]!r}"
|
|
43
|
+
)
|
|
44
|
+
used[folded] = sheet.name
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_extra_sheet(
|
|
48
|
+
name: str,
|
|
49
|
+
path: Path | str,
|
|
50
|
+
*,
|
|
51
|
+
description: str = "",
|
|
52
|
+
master_columns: tuple[str, ...] = (),
|
|
53
|
+
) -> ExtraSheet:
|
|
54
|
+
"""Read a curated CSV from outside ``out_dir`` as an :class:`ExtraSheet`."""
|
|
55
|
+
path = Path(path)
|
|
56
|
+
if not path.is_file():
|
|
57
|
+
raise ValueError(f"Extra sheet {name!r}: no such file: {path}")
|
|
58
|
+
return ExtraSheet(
|
|
59
|
+
name=name,
|
|
60
|
+
frame=pd.read_csv(path),
|
|
61
|
+
description=description or f"Curated table from {path.name}.",
|
|
62
|
+
master_columns=master_columns,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _require_openpyxl() -> None:
|
|
67
|
+
"""Excel writing is an extra, so the base install stays light."""
|
|
68
|
+
try:
|
|
69
|
+
import openpyxl # noqa: F401
|
|
70
|
+
except ImportError as exc: # pragma: no cover - exercised via monkeypatch
|
|
71
|
+
raise ImportError(
|
|
72
|
+
"Writing .xlsx needs openpyxl, which is not a default dependency. "
|
|
73
|
+
'Install it with: pip install "citegraph[excel]"'
|
|
74
|
+
) from exc
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _author_id_columns(frame: pd.DataFrame) -> list[str]:
|
|
78
|
+
return [
|
|
79
|
+
str(column)
|
|
80
|
+
for column in frame.columns
|
|
81
|
+
if str(column) == "author_id" or str(column).endswith("_author_id")
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_core_author_workbook(
|
|
86
|
+
out_dir: Path | str,
|
|
87
|
+
dest: Path | str | None = None,
|
|
88
|
+
*,
|
|
89
|
+
extra_sheets: tuple[ExtraSheet, ...] = (),
|
|
90
|
+
ring: int = 0,
|
|
91
|
+
) -> Path:
|
|
92
|
+
"""Write ``core_authors.xlsx`` for the authors of ``ring``'s works."""
|
|
93
|
+
_validate_extra_sheet_names(extra_sheets)
|
|
94
|
+
_require_openpyxl()
|
|
95
|
+
layout = OutLayout(Path(out_dir))
|
|
96
|
+
works = pd.read_csv(layout.works_csv)
|
|
97
|
+
occurrences = pd.read_csv(layout.author_citations_csv)
|
|
98
|
+
authors = pd.read_csv(layout.authors_csv)
|
|
99
|
+
|
|
100
|
+
core_works = set(works.loc[works["ring"] == ring, "id"])
|
|
101
|
+
core_ids = set(occurrences.loc[occurrences["record_id"].isin(core_works), "author_id"])
|
|
102
|
+
master = authors[authors["id"].isin(core_ids)].copy()
|
|
103
|
+
master["name_variants"] = master["id"].map(_name_variants(occurrences, core_ids))
|
|
104
|
+
works_sheet = _works_sheet(occurrences, works, core_ids)
|
|
105
|
+
|
|
106
|
+
centrality = _optional_frame(layout.author_centrality_csv)
|
|
107
|
+
if centrality is not None and "author_id" in centrality.columns:
|
|
108
|
+
master = master.merge(
|
|
109
|
+
centrality.drop(columns=[c for c in ("Author",) if c in centrality.columns]),
|
|
110
|
+
left_on="id",
|
|
111
|
+
right_on="author_id",
|
|
112
|
+
how="left",
|
|
113
|
+
).drop(columns=["author_id"])
|
|
114
|
+
review = _review_sheet(layout.author_review_json, core_ids)
|
|
115
|
+
|
|
116
|
+
filtered: list[tuple[ExtraSheet, pd.DataFrame, str]] = []
|
|
117
|
+
for sheet in extra_sheets:
|
|
118
|
+
rows = _filter_to_core(sheet, core_ids)
|
|
119
|
+
covered: set[str] = set()
|
|
120
|
+
for column in _author_id_columns(rows):
|
|
121
|
+
covered |= set(rows[column].dropna())
|
|
122
|
+
master[f"in_{sheet.name}"] = master["id"].isin(covered)
|
|
123
|
+
master = _lift_columns(master, sheet, rows)
|
|
124
|
+
n_covered = len(covered & core_ids)
|
|
125
|
+
filtered.append((sheet, rows, f"{n_covered} of {len(core_ids)}"))
|
|
126
|
+
|
|
127
|
+
tables: list[tuple[str, pd.DataFrame, str, str, str]] = [
|
|
128
|
+
("Authors", master, "authors.csv", f"One row per author of a ring-{ring} work.",
|
|
129
|
+
f"{len(master)} of {len(core_ids)}"),
|
|
130
|
+
("Works", works_sheet, "author_citations.csv + works.csv",
|
|
131
|
+
"One row per (author, work), core and cited alike.", ""),
|
|
132
|
+
]
|
|
133
|
+
if review is not None:
|
|
134
|
+
tables.append(
|
|
135
|
+
("Review_Flags", review, "author_review.json",
|
|
136
|
+
"Clusters the author stage flagged as low-confidence.", "")
|
|
137
|
+
)
|
|
138
|
+
tables.extend(
|
|
139
|
+
(sheet.name, rows, "supplied by --extra-csv", sheet.description, coverage)
|
|
140
|
+
for sheet, rows, coverage in filtered
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
path = Path(dest) if dest is not None else layout.core_authors_xlsx
|
|
144
|
+
with pd.ExcelWriter(path, engine="openpyxl") as writer:
|
|
145
|
+
_readme(tables).to_excel(writer, sheet_name="README", index=False)
|
|
146
|
+
for name, frame, _source, _description, _coverage in tables:
|
|
147
|
+
frame.to_excel(writer, sheet_name=name, index=False)
|
|
148
|
+
return path
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _readme(
|
|
152
|
+
tables: list[tuple[str, pd.DataFrame, str, str, str]],
|
|
153
|
+
) -> pd.DataFrame:
|
|
154
|
+
"""A contents page, so the workbook explains itself to whoever opens it.
|
|
155
|
+
|
|
156
|
+
``Core_authors_covered`` is the column to read first on a curated sheet:
|
|
157
|
+
hand-collected metadata never covers everybody, and the gap is a finding
|
|
158
|
+
about the corpus rather than a fault in the export.
|
|
159
|
+
"""
|
|
160
|
+
return pd.DataFrame(
|
|
161
|
+
[
|
|
162
|
+
{
|
|
163
|
+
"Sheet": name,
|
|
164
|
+
"Rows": len(frame),
|
|
165
|
+
"Core_authors_covered": coverage,
|
|
166
|
+
"Source": source,
|
|
167
|
+
"Description": description,
|
|
168
|
+
}
|
|
169
|
+
for name, frame, source, description, coverage in tables
|
|
170
|
+
]
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _optional_frame(path: Path) -> pd.DataFrame | None:
|
|
175
|
+
"""Read a CSV that may not exist, and never fail the export over it."""
|
|
176
|
+
if not path.exists():
|
|
177
|
+
return None
|
|
178
|
+
try:
|
|
179
|
+
return pd.read_csv(path)
|
|
180
|
+
except (OSError, ValueError, pd.errors.ParserError):
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _review_sheet(path: Path, core_ids: set[str]) -> pd.DataFrame | None:
|
|
185
|
+
"""The author-QC flags, for core authors only."""
|
|
186
|
+
if not path.exists():
|
|
187
|
+
return None
|
|
188
|
+
try:
|
|
189
|
+
flags = read_json(path)
|
|
190
|
+
except (OSError, ValueError):
|
|
191
|
+
return None
|
|
192
|
+
if not isinstance(flags, list):
|
|
193
|
+
return None
|
|
194
|
+
rows = [
|
|
195
|
+
{
|
|
196
|
+
key: ("; ".join(str(item) for item in value) if isinstance(value, list) else value)
|
|
197
|
+
for key, value in flag.items()
|
|
198
|
+
}
|
|
199
|
+
for flag in flags
|
|
200
|
+
if isinstance(flag, dict) and flag.get("author_id") in core_ids
|
|
201
|
+
]
|
|
202
|
+
return pd.DataFrame(rows)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _filter_to_core(sheet: ExtraSheet, core_ids: set[str]) -> pd.DataFrame:
|
|
206
|
+
"""Keep a row when *any* of its author-id columns names a core author.
|
|
207
|
+
|
|
208
|
+
One rule covers both shapes the curated tables take: a per-person table
|
|
209
|
+
keyed on ``author_id``, and an edge table with ``advisor_author_id`` and
|
|
210
|
+
``advisee_author_id``, where a tie is worth keeping if either end is
|
|
211
|
+
somebody we published.
|
|
212
|
+
"""
|
|
213
|
+
columns = _author_id_columns(sheet.frame)
|
|
214
|
+
if not columns:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"Extra sheet {sheet.name!r} has no author-id column; expected "
|
|
217
|
+
f"'author_id' or a column ending in '_author_id', got "
|
|
218
|
+
f"{list(sheet.frame.columns)}"
|
|
219
|
+
)
|
|
220
|
+
keep = pd.Series(False, index=sheet.frame.index)
|
|
221
|
+
for column in columns:
|
|
222
|
+
keep |= sheet.frame[column].isin(core_ids)
|
|
223
|
+
return sheet.frame[keep].copy()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
_WORK_COLUMNS = ["ring", "source_file", "Title", "Journal", "Journal_Canonical", "Year"]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _works_sheet(
|
|
230
|
+
occurrences: pd.DataFrame, works: pd.DataFrame, core_ids: set[str]
|
|
231
|
+
) -> pd.DataFrame:
|
|
232
|
+
"""Every work a core author appears on, core and cited alike.
|
|
233
|
+
|
|
234
|
+
Restricting this to ring 0 would answer a question nobody asked: an
|
|
235
|
+
author is interesting partly *because* the corpus cites their other work.
|
|
236
|
+
"""
|
|
237
|
+
mine = occurrences[occurrences["author_id"].isin(core_ids)]
|
|
238
|
+
columns = [column for column in _WORK_COLUMNS if column in works.columns]
|
|
239
|
+
return mine.merge(
|
|
240
|
+
works[["id", *columns]].rename(columns={"id": "record_id"}),
|
|
241
|
+
on="record_id",
|
|
242
|
+
how="left",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _name_variants(occurrences: pd.DataFrame, core_ids: set[str]) -> dict[str, str]:
|
|
247
|
+
"""Every distinct spelling the bibliographies used, per author.
|
|
248
|
+
|
|
249
|
+
This is the evidence the cluster was built from, and the fastest way for
|
|
250
|
+
a reader to see that a merge was right — or that it was not.
|
|
251
|
+
"""
|
|
252
|
+
mine = occurrences[occurrences["author_id"].isin(core_ids)]
|
|
253
|
+
grouped = mine.groupby("author_id")["raw_author"]
|
|
254
|
+
return {
|
|
255
|
+
author_id: "; ".join(sorted({str(name) for name in names if pd.notna(name)}))
|
|
256
|
+
for author_id, names in grouped
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _lift_columns(
|
|
261
|
+
master: pd.DataFrame, sheet: ExtraSheet, rows: pd.DataFrame
|
|
262
|
+
) -> pd.DataFrame:
|
|
263
|
+
"""Copy a curated sheet's headline columns onto the master."""
|
|
264
|
+
if not sheet.master_columns:
|
|
265
|
+
return master
|
|
266
|
+
if "author_id" not in rows.columns:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"Extra sheet {sheet.name!r} lifts columns into the master but has no "
|
|
269
|
+
"'author_id' column to key them on"
|
|
270
|
+
)
|
|
271
|
+
duplicated = rows["author_id"].duplicated().any()
|
|
272
|
+
if duplicated:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
f"Extra sheet {sheet.name!r} lifts columns into the master but does not "
|
|
275
|
+
"have one row per author; lift from a per-person table instead"
|
|
276
|
+
)
|
|
277
|
+
missing = [name for name in sheet.master_columns if name not in rows.columns]
|
|
278
|
+
if missing:
|
|
279
|
+
raise ValueError(
|
|
280
|
+
f"Extra sheet {sheet.name!r} cannot lift {missing}; it has "
|
|
281
|
+
f"{list(rows.columns)}"
|
|
282
|
+
)
|
|
283
|
+
clash = [name for name in sheet.master_columns if name in master.columns]
|
|
284
|
+
if clash:
|
|
285
|
+
raise ValueError(
|
|
286
|
+
f"Extra sheet {sheet.name!r} would overwrite existing column(s) {clash} "
|
|
287
|
+
"on the Authors sheet; rename them in the source table"
|
|
288
|
+
)
|
|
289
|
+
return master.merge(
|
|
290
|
+
rows[["author_id", *sheet.master_columns]],
|
|
291
|
+
left_on="id",
|
|
292
|
+
right_on="author_id",
|
|
293
|
+
how="left",
|
|
294
|
+
).drop(columns=["author_id"])
|