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 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")
@@ -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"])