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
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: citegraph
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Build a deduplicated citation graph from a folder of academic PDFs using Gemini.
|
|
5
|
+
Project-URL: Homepage, https://github.com/yabramuvdi/citegraph
|
|
6
|
+
Project-URL: Repository, https://github.com/yabramuvdi/citegraph
|
|
7
|
+
Project-URL: Issues, https://github.com/yabramuvdi/citegraph/issues
|
|
8
|
+
Author-email: Yabra Muvdi <yabran.muvdi@gmail.com>, María Gutiérrez Ruan <maria.gutierrezruan@unil.ch>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: academic-papers,bibliometrics,citations,gemini,pdf
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: click<8.2
|
|
22
|
+
Requires-Dist: google-genai>=0.3
|
|
23
|
+
Requires-Dist: pandas>=2.0
|
|
24
|
+
Requires-Dist: pydantic-settings>=2.2
|
|
25
|
+
Requires-Dist: pydantic>=2.6
|
|
26
|
+
Requires-Dist: python-slugify>=8.0
|
|
27
|
+
Requires-Dist: rapidfuzz>=3.6
|
|
28
|
+
Requires-Dist: rich>=13.0
|
|
29
|
+
Requires-Dist: tenacity>=8.2
|
|
30
|
+
Requires-Dist: typer<0.13,>=0.12
|
|
31
|
+
Provides-Extra: all
|
|
32
|
+
Requires-Dist: docling[easyocr]>=2.0; extra == 'all'
|
|
33
|
+
Requires-Dist: httpx>=0.27; extra == 'all'
|
|
34
|
+
Requires-Dist: matplotlib>=3.8; extra == 'all'
|
|
35
|
+
Requires-Dist: networkx>=3.0; extra == 'all'
|
|
36
|
+
Requires-Dist: openpyxl>=3.1; extra == 'all'
|
|
37
|
+
Provides-Extra: crossref
|
|
38
|
+
Requires-Dist: httpx>=0.27; extra == 'crossref'
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
41
|
+
Requires-Dist: matplotlib>=3.8; extra == 'dev'
|
|
42
|
+
Requires-Dist: networkx>=3.0; extra == 'dev'
|
|
43
|
+
Requires-Dist: openpyxl>=3.1; extra == 'dev'
|
|
44
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
45
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
46
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
47
|
+
Provides-Extra: excel
|
|
48
|
+
Requires-Dist: openpyxl>=3.1; extra == 'excel'
|
|
49
|
+
Provides-Extra: graph
|
|
50
|
+
Requires-Dist: networkx>=3.0; extra == 'graph'
|
|
51
|
+
Provides-Extra: pdf
|
|
52
|
+
Requires-Dist: docling[easyocr]>=2.0; extra == 'pdf'
|
|
53
|
+
Provides-Extra: plots
|
|
54
|
+
Requires-Dist: matplotlib>=3.8; extra == 'plots'
|
|
55
|
+
Description-Content-Type: text/markdown
|
|
56
|
+
|
|
57
|
+
# citegraph
|
|
58
|
+
|
|
59
|
+
Turn a folder of academic PDFs into a citation graph you can query, and then
|
|
60
|
+
ask who the literature reads together.
|
|
61
|
+
|
|
62
|
+
`citegraph` is for a researcher who has a corpus of papers — a literature
|
|
63
|
+
review, a field's working papers, a lab's output — and wants to study it
|
|
64
|
+
bibliometrically without hand-coding bibliographies. It converts each PDF to
|
|
65
|
+
text locally, has a large language model extract the paper's metadata and its
|
|
66
|
+
full reference list, resolves every bibliographic record into one canonical
|
|
67
|
+
**work**, and clusters author names across the corpus into canonical
|
|
68
|
+
**authors**. On top of those tables it builds the author co-citation network
|
|
69
|
+
and offers a test for whether a documented relationship between two people
|
|
70
|
+
shows up in how the field cites them.
|
|
71
|
+
|
|
72
|
+
Everything lands as plain CSVs in one output directory, every stage is
|
|
73
|
+
checkpointed, and nothing is sent anywhere until you have seen a cost estimate.
|
|
74
|
+
|
|
75
|
+
## What you get
|
|
76
|
+
|
|
77
|
+
Two questions, answered from the same output directory.
|
|
78
|
+
|
|
79
|
+
**What does this corpus cite?** `works.csv` holds every bibliographic record,
|
|
80
|
+
your own PDFs at `ring` 0 and everything they cite at `ring` 1, and
|
|
81
|
+
`citation_graph.csv` holds the work-to-work edges. A citation of one of your
|
|
82
|
+
own papers resolves to that same work, so the graph includes your papers citing
|
|
83
|
+
each other. `authors.csv` and `author_citations.csv` do the same for people.
|
|
84
|
+
|
|
85
|
+
**Who is read together?** `CitationGraph.author_cocitation_network()` builds
|
|
86
|
+
the author co-citation projection (Small 1973; White & Griffith 1981): two
|
|
87
|
+
authors are joined when the same bibliography cites both. It is a genuine
|
|
88
|
+
one-mode network on which degree, betweenness and eigenvector centrality are
|
|
89
|
+
defined. `tie_cocitation_test()` then asks whether a documented tie — doctoral
|
|
90
|
+
supervision, co-authorship, a shared institution — is co-cited more than a
|
|
91
|
+
comparable pair of strangers, against a null that holds each author's
|
|
92
|
+
prominence fixed.
|
|
93
|
+
|
|
94
|
+
## What you need
|
|
95
|
+
|
|
96
|
+
- **Python 3.11 or newer.**
|
|
97
|
+
- **A Google Gemini API key.** The extraction stages call Gemini and nothing
|
|
98
|
+
else; there is no offline or alternative-provider path yet. Set
|
|
99
|
+
`GOOGLE_API_KEY` in the environment or in a `.env` file. The default model
|
|
100
|
+
is a lite tier; `citegraph estimate` prints the token count and dollar cost
|
|
101
|
+
for your corpus before any call is made, and the pipeline asks for
|
|
102
|
+
confirmation.
|
|
103
|
+
- **Disk and patience for the PDF extra.** PDF conversion uses
|
|
104
|
+
[`docling`](https://github.com/DS4SD/docling), which pulls in a large machine
|
|
105
|
+
learning stack. Install it once in a fresh virtual environment.
|
|
106
|
+
- **Optionally, an OpenAlex API key** (`OPENALEX_API_KEY`, free from
|
|
107
|
+
openalex.org) if you want DOIs and author identifiers from CrossRef and
|
|
108
|
+
OpenAlex. Without it the enrichment stage still works but hits a small daily
|
|
109
|
+
budget.
|
|
110
|
+
|
|
111
|
+
What leaves your computer:
|
|
112
|
+
|
|
113
|
+
| Stage | Service | Data sent externally |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| PDF conversion | Docling, local | None by citegraph; Docling may download model files |
|
|
116
|
+
| Metadata extraction | Google Gemini | A bounded portion of each paper's converted text |
|
|
117
|
+
| Reference extraction | Google Gemini | The detected bibliography section, or the full text when none is found |
|
|
118
|
+
| Canonicalization, authors, co-citation | Local | None |
|
|
119
|
+
| Enrichment (optional) | CrossRef and OpenAlex | Extracted titles, authors, and query parameters |
|
|
120
|
+
|
|
121
|
+
Do not process confidential, embargoed or license-restricted documents until
|
|
122
|
+
their disclosure to those services has been approved.
|
|
123
|
+
|
|
124
|
+
## Install
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pip install "citegraph[pdf]" # PDF-to-graph pipeline
|
|
128
|
+
pip install "citegraph[all]" # + CrossRef/OpenAlex enrichment, networkx, plots, Excel export
|
|
129
|
+
pip install citegraph # lightweight core for querying an existing output directory
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The base install is enough to load a finished run with `CitationGraph`; the
|
|
133
|
+
`[pdf]` or `[all]` extra is required to start from PDFs. Install `[graph]` (or
|
|
134
|
+
`[all]`) for the co-citation network, which returns a `networkx` graph.
|
|
135
|
+
|
|
136
|
+
## Quickstart
|
|
137
|
+
|
|
138
|
+
Run it in stages. Each stage reads the previous one's artifacts from `--out`,
|
|
139
|
+
so you get an inspection point before every expensive step:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
export GOOGLE_API_KEY="your-key-here"
|
|
143
|
+
|
|
144
|
+
citegraph convert ./pdfs --out ./out --ocr-auto # local; writes out/markdown/
|
|
145
|
+
citegraph estimate --out ./out # tokens and USD, no API calls
|
|
146
|
+
citegraph metadata --out ./out # Gemini -> sources.csv
|
|
147
|
+
citegraph references --out ./out # Gemini -> citations_raw.csv
|
|
148
|
+
citegraph dedup --out ./out # -> works.csv, citation_graph.csv
|
|
149
|
+
citegraph enrich --out ./out --enrich-contact you@example.com # optional
|
|
150
|
+
citegraph authors --out ./out # -> authors.csv, author_citations.csv
|
|
151
|
+
citegraph report --out ./out --open # QC dashboard in the browser
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Or in one go: `citegraph run ./pdfs --out ./out --ocr-auto --enrich --enrich-contact you@example.com`.
|
|
155
|
+
`run` and `references` print the cost estimate and wait for a `y`; pass
|
|
156
|
+
`--yes` to skip the prompt when scripting (with no TTY the prompt resolves to
|
|
157
|
+
"no" and the stage exits having done nothing).
|
|
158
|
+
|
|
159
|
+
The same from Python:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from citegraph import Pipeline
|
|
163
|
+
from citegraph.enrich import EnrichConfig
|
|
164
|
+
|
|
165
|
+
pipe = Pipeline(
|
|
166
|
+
pdf_dir="./pdfs",
|
|
167
|
+
out_dir="./out",
|
|
168
|
+
ocr="auto",
|
|
169
|
+
enrich=True,
|
|
170
|
+
enrich_config=EnrichConfig(contact_email="you@example.com"),
|
|
171
|
+
)
|
|
172
|
+
print(pipe.estimate_extraction_cost().format_summary())
|
|
173
|
+
result = pipe.run()
|
|
174
|
+
|
|
175
|
+
result.works # canonical works (ring 0 = your PDFs)
|
|
176
|
+
result.graph # (citing_id, cited_id) edges
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Re-running skips work already done, so an interrupted run wastes no API calls.
|
|
180
|
+
|
|
181
|
+
### Try it without a key
|
|
182
|
+
|
|
183
|
+
A finished run ships in the repository under
|
|
184
|
+
[`examples/demo_out/`](examples/demo_out/): one experimental economist's 93
|
|
185
|
+
papers and everything they cite, as the five generated tables. From a checkout:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
git clone https://github.com/yabramuvdi/citegraph && cd citegraph
|
|
189
|
+
pip install "citegraph[graph]"
|
|
190
|
+
citegraph report --out examples/demo_out --open
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from citegraph import CitationGraph
|
|
195
|
+
|
|
196
|
+
g = CitationGraph.from_out_dir("examples/demo_out")
|
|
197
|
+
g # <CitationGraph: 93 core works, 3277 works, 4532 edges, 4738 authors>
|
|
198
|
+
g.top_cited(5)[["Title", "Year", "citation_count"]]
|
|
199
|
+
g.top_cited_authors(5)[["display_name", "n_citations_received"]]
|
|
200
|
+
|
|
201
|
+
G = g.author_cocitation_network(min_papers=3)
|
|
202
|
+
G["a-ostrom-elinor"]["a-cardenas-juan-camilo"]["weight"] # 53 bibliographies cite both
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Every example below runs against it.
|
|
206
|
+
|
|
207
|
+
## Using citegraph with an AI agent
|
|
208
|
+
|
|
209
|
+
The pipeline is deliberately shaped so a coding agent can drive it: every
|
|
210
|
+
stage is a command, every stage leaves artifacts a program can check, and
|
|
211
|
+
corrections live in curated files rather than in edits to generated ones.
|
|
212
|
+
Paste this into your agent's instructions, then describe your corpus and your
|
|
213
|
+
question.
|
|
214
|
+
|
|
215
|
+
> I want to analyse a folder of academic PDFs with the `citegraph` Python
|
|
216
|
+
> package (`pip install "citegraph[all]"`). Start by running `citegraph --help`
|
|
217
|
+
> and reading https://github.com/yabramuvdi/citegraph/blob/main/docs/USER_GUIDE.md.
|
|
218
|
+
>
|
|
219
|
+
> - Run the pipeline stage by stage: `convert`, `estimate`, `metadata`,
|
|
220
|
+
> `references`, `dedup`, `enrich`, `authors`, `report`. Show me the output of
|
|
221
|
+
> `citegraph estimate` and wait for my approval before any stage that calls
|
|
222
|
+
> the LLM. Never pass `--yes` on your own.
|
|
223
|
+
> - API keys live in `.env`. Never read, print or copy them.
|
|
224
|
+
> - After every stage run `citegraph status` and check the output directory for
|
|
225
|
+
> `conversion_warnings.json`, `*_failures.jsonl`, `source_duplicates.json`,
|
|
226
|
+
> `papers_no_references.json` and `author_review.json`. Tell me what they say
|
|
227
|
+
> before moving on.
|
|
228
|
+
> - Never edit `works.csv`, `authors.csv` or `citation_graph.csv` by hand: they
|
|
229
|
+
> are regenerated. Corrections go in `author_aliases.csv`,
|
|
230
|
+
> `author_external_ids.csv`, `journal_aliases.csv` or
|
|
231
|
+
> `work_annotations.csv`, followed by re-running the stage that reads them.
|
|
232
|
+
> - Answer questions about the corpus with
|
|
233
|
+
> `CitationGraph.from_out_dir(out_dir)` in Python rather than by parsing the
|
|
234
|
+
> CSVs yourself. Report counts from `run_summary.json`, not estimates.
|
|
235
|
+
> - Processing a paper sends its text to an external LLM provider. Ask me before
|
|
236
|
+
> processing anything confidential or embargoed.
|
|
237
|
+
|
|
238
|
+
## Analysing the corpus
|
|
239
|
+
|
|
240
|
+
### Querying the graph
|
|
241
|
+
|
|
242
|
+
`CitationGraph` is a small read-only view over the finished tables. Construct
|
|
243
|
+
it from a run's `out_dir` (the common case: extraction yesterday, questions
|
|
244
|
+
today) or from a freshly returned `PipelineResult`:
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
from citegraph import CitationGraph
|
|
248
|
+
|
|
249
|
+
g = CitationGraph.from_out_dir("examples/demo_out") # or "./out"
|
|
250
|
+
# or: g = CitationGraph.from_pipeline_result(pipe.run())
|
|
251
|
+
|
|
252
|
+
g # <CitationGraph: 93 core works, 3277 works, 4532 edges, 4738 authors>
|
|
253
|
+
g.n_core_works, g.n_works, g.n_edges
|
|
254
|
+
|
|
255
|
+
g.core # your own papers (ring 0)
|
|
256
|
+
g.core_citations() # who among your papers cites whom
|
|
257
|
+
g.top_cited(n=10) # most-cited works (your own papers rank too)
|
|
258
|
+
g.citers_of("w-ostrom-1994-rules-games-and-common-pool-resources") # 29 of the core papers
|
|
259
|
+
g.cited_by(g.core.index[0]) # one core paper's bibliography
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
When `authors.csv` and `author_citations.csv` exist, author-level queries
|
|
263
|
+
are available too. These join through `citation_graph.csv`, so source-paper
|
|
264
|
+
journal counts answer "which journals are doing the citing?":
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
cardenas_id = g.find_author("cardenas").index[0]
|
|
268
|
+
|
|
269
|
+
g.top_authors(10, ring=0) # most prominent authors of YOUR papers
|
|
270
|
+
g.top_cited_authors(10) # most-cited authors in the corpus
|
|
271
|
+
g.citation_context_for_author(cardenas_id) # one citing-work -> cited-work edge per row
|
|
272
|
+
g.citing_papers_by_author(cardenas_id) # distinct source papers citing that author
|
|
273
|
+
g.source_journals_citing_author(cardenas_id) # source-paper journal rollup
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`g.to_networkx()` exports the work-level graph as a `DiGraph` with edges
|
|
277
|
+
citing → cited (`pip install "citegraph[graph]"`).
|
|
278
|
+
|
|
279
|
+
### Author co-citation networks
|
|
280
|
+
|
|
281
|
+
`to_networkx()` exports the citation graph as it stands, which is one hop
|
|
282
|
+
deep: only works you processed a PDF for have outgoing edges. Path-based
|
|
283
|
+
centrality over that graph would rank the PDFs you happened to collect rather
|
|
284
|
+
than the literature.
|
|
285
|
+
|
|
286
|
+
`author_cocitation_network()` builds the standard alternative from
|
|
287
|
+
bibliometrics — the **author co-citation projection** (White & Griffith 1981;
|
|
288
|
+
White & McCain 1998, following Small 1973 on document co-citation). Two
|
|
289
|
+
authors are joined when the same bibliography cites both, and the tie weight
|
|
290
|
+
counts how many bibliographies do so.
|
|
291
|
+
|
|
292
|
+

|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
G = g.author_cocitation_network(min_papers=3) # weighted, undirected networkx.Graph
|
|
296
|
+
|
|
297
|
+
ostrom_id = g.find_author("ostrom").index[0]
|
|
298
|
+
G.nodes[ostrom_id]["display_name"] # also: n_citing_papers
|
|
299
|
+
G[ostrom_id][cardenas_id]["weight"] # bibliographies citing both
|
|
300
|
+
|
|
301
|
+
import networkx as nx
|
|
302
|
+
nx.degree_centrality(G) # or betweenness, eigenvector, ...
|
|
303
|
+
|
|
304
|
+
# Self-citation robustness variant: drop this author's own papers
|
|
305
|
+
# from the citing side.
|
|
306
|
+
own = set(g.citing_papers_by_author(cardenas_id)["paper_id"])
|
|
307
|
+
G_ex = g.author_cocitation_network(min_papers=3, citing_ids=set(g.core.index) - own)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The ties are undirected and imply no contact between the authors — a third
|
|
311
|
+
party's reference list is what creates them. Two construction rules follow,
|
|
312
|
+
both drawn in
|
|
313
|
+
[`cocitation_projection.svg`](docs/figures/cocitation_projection.svg): a
|
|
314
|
+
citing work naming several works by one author still contributes a single
|
|
315
|
+
mention, and no author is ever joined to themselves. `min_papers` prunes the
|
|
316
|
+
long tail of once-cited names.
|
|
317
|
+
[`cocitation_why.svg`](docs/figures/cocitation_why.svg) contrasts the
|
|
318
|
+
projection with the work-level graph. Nodes are added in sorted order so a
|
|
319
|
+
seeded layout is reproducible; a `subgraph()` of it is not, so re-sort the
|
|
320
|
+
nodes before drawing a subset.
|
|
321
|
+
|
|
322
|
+
### Does a documented tie show up in the citation record?
|
|
323
|
+
|
|
324
|
+
You often know something about the people in a corpus that the bibliographies
|
|
325
|
+
do not say: who supervised whom, who shares a department, who co-authored.
|
|
326
|
+
`tie_cocitation_test` asks whether such a tie is visible in co-citation —
|
|
327
|
+
whether the two are cited together more than a *comparable* pair of strangers.
|
|
328
|
+
|
|
329
|
+
"Comparable" is the whole problem. Advisors and their students are prominent,
|
|
330
|
+
and prominent authors are co-cited with nearly everyone, so beating an average
|
|
331
|
+
pair proves nothing. The test therefore replaces each end of a tie with authors
|
|
332
|
+
cited by a similar number of bibliographies (`degree_tolerance`, a band around
|
|
333
|
+
each author's count), draws pairs from those strata, and reports where the tie's
|
|
334
|
+
observed count falls in that distribution.
|
|
335
|
+
|
|
336
|
+
Give it a `CitationGraph` and author-id pairs. A supervision table with
|
|
337
|
+
`advisor_author_id` and `advisee_author_id` columns is the natural input:
|
|
338
|
+
|
|
339
|
+
```python
|
|
340
|
+
import pandas as pd
|
|
341
|
+
from citegraph import CitationGraph
|
|
342
|
+
from citegraph.cocitation_stats import tie_cocitation_test
|
|
343
|
+
|
|
344
|
+
g = CitationGraph.from_out_dir("./out")
|
|
345
|
+
ties = pd.read_csv("supervision.csv") # advisor_author_id, advisee_author_id
|
|
346
|
+
|
|
347
|
+
result = tie_cocitation_test(
|
|
348
|
+
g,
|
|
349
|
+
ties[["advisor_author_id", "advisee_author_id"]].itertuples(index=False, name=None),
|
|
350
|
+
min_papers=3, # must match the network a companion figure draws
|
|
351
|
+
exclude_self_citations=True, # score on third-party bibliographies only
|
|
352
|
+
n_draws=2000,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
result.to_frame() # one row per tie: observed, null_median, p_value, pool_size, n_eligible
|
|
356
|
+
result.n_significant # ties with p < alpha ...
|
|
357
|
+
result.expected_significant # ... against how many chance alone would give
|
|
358
|
+
for tie in result.skipped: # ties the data could not carry, with the reason
|
|
359
|
+
print(tie.source, tie.target, tie.reason)
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Three things to know before reporting the numbers:
|
|
363
|
+
|
|
364
|
+
- **`exclude_self_citations` defines a universe, not a filter on one side.**
|
|
365
|
+
The bibliographies either member wrote are removed, and the tie *and* its
|
|
366
|
+
comparison pairs are counted and prominence-matched inside what remains. The
|
|
367
|
+
intuitive version, filtering only the tie, compares authors who lost most of
|
|
368
|
+
their chances against authors who lost none.
|
|
369
|
+
- **Ties are usually not independent.** One advisor appears in several ties,
|
|
370
|
+
so a combined p-value over the set is not confirmatory evidence. Report the
|
|
371
|
+
per-tie permutation results, and `n_significant` against
|
|
372
|
+
`expected_significant` as a description. `result.fisher_p_value` exists for
|
|
373
|
+
the case where your ties really are independent.
|
|
374
|
+
- **Read `result.skipped` before claiming coverage.** A tie is skipped, with a
|
|
375
|
+
reason, when an end is cited by fewer than `min_papers` bibliographies or has
|
|
376
|
+
no degree-matched substitute. A skipped tie is not a null result.
|
|
377
|
+
|
|
378
|
+
Each tie seeds its own random generator from its own identity, so a tie's
|
|
379
|
+
numbers do not depend on the order ties arrive in or how many came with them.
|
|
380
|
+
|
|
381
|
+
## Reviewing a run
|
|
382
|
+
|
|
383
|
+
`citegraph report` writes a single self-contained `report.html` into the out
|
|
384
|
+
directory: stage progress, a problems summary, a per-paper table with expandable
|
|
385
|
+
metadata / reference / markdown previews, a dedup merge audit, enrichment match
|
|
386
|
+
quality, author-cluster review, and an artifact integrity check. It makes no
|
|
387
|
+
API or network calls and reads whatever artifacts exist, so it is designed to
|
|
388
|
+
be re-run after every stage — sections for stages you haven't run yet show a
|
|
389
|
+
placeholder with the command to run next.
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
citegraph report --out ./out # write ./out/report.html
|
|
393
|
+
citegraph report --out ./out --open # …and open it in your browser
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
`citegraph ui` serves the same information as a small local web app that
|
|
397
|
+
refreshes itself while a run is in progress. It is read-only, binds to
|
|
398
|
+
`127.0.0.1` only, and needs no extra dependencies. It is **experimental**: the
|
|
399
|
+
first slice of the researcher-facing application sketched in
|
|
400
|
+
[docs/UI_ARCHITECTURE.md](docs/UI_ARCHITECTURE.md), and its interface may
|
|
401
|
+
change.
|
|
402
|
+
|
|
403
|
+
```bash
|
|
404
|
+
citegraph ui --out ./out --open # http://127.0.0.1:8765/ — Ctrl+C to stop
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Analysis helpers
|
|
408
|
+
|
|
409
|
+
These commands grew out of one manuscript and are general enough to ship, but
|
|
410
|
+
they are not the core offering and their interfaces may change between minor
|
|
411
|
+
versions.
|
|
412
|
+
|
|
413
|
+
### `export-authors`: one workbook for the authors of your own papers
|
|
414
|
+
|
|
415
|
+
`authors.csv` describes every author in the corpus, most of them met once in
|
|
416
|
+
somebody else's bibliography. `citegraph export-authors` narrows that to the
|
|
417
|
+
authors of your own PDFs and gathers everything known about them into a single
|
|
418
|
+
spreadsheet, keyed on `author_id` throughout:
|
|
419
|
+
|
|
420
|
+
```bash
|
|
421
|
+
citegraph export-authors --out ./out # -> ./out/core_authors.xlsx
|
|
422
|
+
citegraph export-authors --out ./out --ring 1 # authors first met one hop out
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
| Sheet | Content |
|
|
426
|
+
| ----- | ------- |
|
|
427
|
+
| `README` | What each sheet is, its row count, and how many authors it covers |
|
|
428
|
+
| `Authors` | One row per author: identity, external ids, citation metrics, every raw spelling the corpus used, and co-citation centrality when `author_centrality.csv` is present |
|
|
429
|
+
| `Works` | One row per (author, work) — their cited works as well as your own |
|
|
430
|
+
| `Review_Flags` | The low-confidence clusters among them, from `author_review.json` |
|
|
431
|
+
|
|
432
|
+
Curated tables kept outside `out_dir` — hand-collected affiliations, career
|
|
433
|
+
histories, supervision ties — are added with `--extra-csv`, and
|
|
434
|
+
`--master-column` lifts a headline field onto the `Authors` sheet:
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
citegraph export-authors --out ./out \
|
|
438
|
+
--extra-csv Careers=../careers.csv \
|
|
439
|
+
--extra-csv Supervision=../supervision.csv \
|
|
440
|
+
--master-column Careers=affiliation_institution
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
A row is kept when any column named `author_id`, or ending in `_author_id`,
|
|
444
|
+
names one of these authors — so an edge table survives when *either* end is
|
|
445
|
+
somebody you published, which is usually how supervision data is shaped. Each
|
|
446
|
+
added sheet also contributes an `in_<sheet>` flag to `Authors` and a coverage
|
|
447
|
+
count to `README`; hand-collected metadata rarely covers everybody, and seeing
|
|
448
|
+
who it misses is the point. Writing `.xlsx` needs `openpyxl`:
|
|
449
|
+
`pip install "citegraph[excel]"`.
|
|
450
|
+
|
|
451
|
+
### Hand-curated annotations
|
|
452
|
+
|
|
453
|
+
Facts a reviewer records about a work — a theme, a publication type — cannot
|
|
454
|
+
live in `works.csv`, which `dedup` regenerates. They go in
|
|
455
|
+
`work_annotations.csv` beside it, keyed on the work `id`, and
|
|
456
|
+
`CitationGraph.from_out_dir` joins them onto `works` at load, so
|
|
457
|
+
`g.core["Theme"]` just works. An optional `annotation_schema.csv`
|
|
458
|
+
(`column,type,allowed,description`) declares enum columns, which are validated
|
|
459
|
+
on load so a bad value fails naming the work instead of becoming a stray bar in
|
|
460
|
+
a figure. Ids that name no work raise rather than being dropped.
|
|
461
|
+
|
|
462
|
+
### JEL codes of your own papers
|
|
463
|
+
|
|
464
|
+
`citegraph jel` reads the JEL classification codes printed on each core paper's
|
|
465
|
+
title page back out of the converted markdown — no model or network calls — and
|
|
466
|
+
writes `jel_codes.csv` with one row per core paper:
|
|
467
|
+
|
|
468
|
+
```bash
|
|
469
|
+
citegraph jel --out ./out # -> ./out/jel_codes.csv, and a list of papers without codes
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
| Column | Meaning |
|
|
473
|
+
| ------ | ------- |
|
|
474
|
+
| `JEL_Codes` | the codes, `;`-separated (`C93;D71;Q20`) |
|
|
475
|
+
| `JEL_General` | their general categories, the first letters (`C;D;Q`) |
|
|
476
|
+
| `JEL_Source` | `extracted` from the paper, `manual`, or `missing` |
|
|
477
|
+
|
|
478
|
+
Many working papers, chapters and dissertations print none. For those, add a
|
|
479
|
+
`JEL_Codes` column to `work_annotations.csv` and re-run; a hand-assigned value
|
|
480
|
+
overrides extraction. `citegraph.jel.JEL_GENERAL_CATEGORIES` maps each letter
|
|
481
|
+
to its name for plotting.
|
|
482
|
+
|
|
483
|
+
## Scanned PDFs
|
|
484
|
+
|
|
485
|
+
If some of your PDFs are scanned (each page is a bitmap with no selectable text),
|
|
486
|
+
use `ocr="auto"` to convert normally first and re-run only image-only outputs
|
|
487
|
+
with EasyOCR:
|
|
488
|
+
|
|
489
|
+
```python
|
|
490
|
+
pipe = Pipeline(pdf_dir="./pdfs", out_dir="./out", ocr="auto")
|
|
491
|
+
result = pipe.run()
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
Or with the CLI:
|
|
495
|
+
|
|
496
|
+
```bash
|
|
497
|
+
citegraph convert ./pdfs --out ./out --ocr-auto
|
|
498
|
+
citegraph run ./pdfs --out ./out --ocr-auto
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Use `ocr=True` / `--ocr` when you want to force full-page OCR for every PDF.
|
|
502
|
+
|
|
503
|
+
After each conversion run `citegraph` writes a `conversion_warnings.json` sidecar
|
|
504
|
+
listing any markdown files that appear to be image-only (fewer than 200
|
|
505
|
+
substantive text characters after stripping image tags) or that lost too many
|
|
506
|
+
characters to a font docling could not map. If you see warnings there,
|
|
507
|
+
re-running with `--ocr-auto` is usually the fastest fix; `--ocr` is the
|
|
508
|
+
force-everything option.
|
|
509
|
+
|
|
510
|
+
## Cost estimate
|
|
511
|
+
|
|
512
|
+
`citegraph run` converts your PDFs to markdown first, then prints an estimated
|
|
513
|
+
token usage and dollar cost for the LLM extraction stages and waits for
|
|
514
|
+
confirmation before making any API calls. Pass `--yes` / `-y` to skip the
|
|
515
|
+
prompt.
|
|
516
|
+
|
|
517
|
+
To inspect the estimate without running the pipeline:
|
|
518
|
+
|
|
519
|
+
```bash
|
|
520
|
+
citegraph convert ./pdfs --out ./out # produce markdown
|
|
521
|
+
citegraph estimate --out ./out # report tokens / cost; no API calls
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Or from the library:
|
|
525
|
+
|
|
526
|
+
```python
|
|
527
|
+
estimate = pipe.estimate_extraction_cost()
|
|
528
|
+
print(estimate.format_summary())
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
Files whose `metadata/<stem>.json` and `references/<stem>.json` caches already
|
|
532
|
+
exist are excluded from the totals. Pricing numbers are approximate standard
|
|
533
|
+
paid-tier Gemini rates, last checked 2026-09-04; the table lives in
|
|
534
|
+
[`src/citegraph/cost_estimation.py`](src/citegraph/cost_estimation.py) and
|
|
535
|
+
should be checked against https://ai.google.dev/gemini-api/docs/pricing before
|
|
536
|
+
trusting the output for budgeting.
|
|
537
|
+
|
|
538
|
+
## Real smoke test
|
|
539
|
+
|
|
540
|
+
For a new corpus, especially while tuning OCR, LLM concurrency, or enrichment,
|
|
541
|
+
prefer a staged smoke test over a single `run` command. This uses the same
|
|
542
|
+
checkpointed artifacts as the full pipeline, but gives you an inspection point
|
|
543
|
+
after every externally fragile step.
|
|
544
|
+
|
|
545
|
+
From a repository checkout, the maintained helper is:
|
|
546
|
+
|
|
547
|
+
```bash
|
|
548
|
+
ENRICH_CONTACT="you@example.com" scripts/smoke_test.sh /path/to/pdf-folder
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Set `OUT_DIR`, `MODEL`, `LLM_CONCURRENCY`, `ENRICH_MAX_WORKERS`, or
|
|
552
|
+
`ENRICH_TIMEOUT` in the environment to override the defaults.
|
|
553
|
+
|
|
554
|
+
If you installed from PyPI, use the CLI directly. The expanded command sequence
|
|
555
|
+
is:
|
|
556
|
+
|
|
557
|
+
```bash
|
|
558
|
+
export PDF_DIR="/path/to/pdf-folder"
|
|
559
|
+
export OUT_DIR="/tmp/citegraph_smoke_out"
|
|
560
|
+
|
|
561
|
+
citegraph convert "$PDF_DIR" --out "$OUT_DIR" --recursive --ocr-auto --verbose
|
|
562
|
+
citegraph estimate --out "$OUT_DIR" --model gemini-3.1-flash-lite
|
|
563
|
+
|
|
564
|
+
citegraph metadata --out "$OUT_DIR" --model gemini-3.1-flash-lite --llm-concurrency 4
|
|
565
|
+
citegraph references --out "$OUT_DIR" --model gemini-3.1-flash-lite --llm-concurrency 4 --yes
|
|
566
|
+
|
|
567
|
+
citegraph dedup --out "$OUT_DIR"
|
|
568
|
+
citegraph enrich --out "$OUT_DIR" \
|
|
569
|
+
--enrich-contact "you@example.com" \
|
|
570
|
+
--enrich-max-workers 2 \
|
|
571
|
+
--enrich-timeout 15
|
|
572
|
+
citegraph authors --out "$OUT_DIR"
|
|
573
|
+
|
|
574
|
+
citegraph status --out "$OUT_DIR"
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
Two details matter in practice:
|
|
578
|
+
|
|
579
|
+
- Keep `--ocr-auto` on the first pass unless you already know every PDF has
|
|
580
|
+
selectable text. If conversion leaves `conversion_warnings.json`, inspect it
|
|
581
|
+
before paying for LLM extraction.
|
|
582
|
+
- Use a real `--enrich-contact` and a modest `--enrich-max-workers` value when
|
|
583
|
+
querying CrossRef/OpenAlex. This is slower, but friendlier to provider rate
|
|
584
|
+
limits; re-runs reuse the per-work cache in `OUT_DIR/enrichment/`.
|
|
585
|
+
|
|
586
|
+
## Pipeline
|
|
587
|
+
|
|
588
|
+

|
|
589
|
+
|
|
590
|
+
After canonicalization, two optional stages complete the pipeline: CrossRef/OpenAlex
|
|
591
|
+
enrichment (DOIs and external author ids for every work) and author normalization
|
|
592
|
+
(`authors.csv` + `author_citations.csv`). A PDF version of the figure lives at
|
|
593
|
+
[docs/figures/pipeline.pdf](docs/figures/pipeline.pdf).
|
|
594
|
+
|
|
595
|
+
## Output schema
|
|
596
|
+
|
|
597
|
+
After a run, `out_dir/` contains the six CSVs below plus a few sidecar
|
|
598
|
+
files described at the end of this section. The per-stage caches live
|
|
599
|
+
under `markdown/`, `metadata/<stem>.json`,
|
|
600
|
+
`references/<stem>.json`, and (when enrichment runs)
|
|
601
|
+
`enrichment/` — safe to inspect, safe to delete to
|
|
602
|
+
force a re-extraction.
|
|
603
|
+
|
|
604
|
+
### `sources.csv` — one row per processed PDF (stage-2 checkpoint)
|
|
605
|
+
|
|
606
|
+
| Column | Type | Notes |
|
|
607
|
+
| ------ | ---- | ----- |
|
|
608
|
+
| `id` | string | `w-<surname>-<year>-<title-slug>`, deterministic across runs |
|
|
609
|
+
| `Title` | string | as extracted by the LLM |
|
|
610
|
+
| `Authors_List` | string | a Python list serialized via `repr`, e.g. `"['Jane Doe', 'John Smith']"` — round-trip with `ast.literal_eval` |
|
|
611
|
+
| `Authors` | string | derived `", ".join(Authors_List)`, ready for display |
|
|
612
|
+
| `Journal` | string | venue / publication |
|
|
613
|
+
| `Year` | int | publication year (`0` is the missing-value sentinel) |
|
|
614
|
+
| `source_file` | string | original PDF filename |
|
|
615
|
+
|
|
616
|
+
### `citations_raw.csv` — one row per *citation event* (before canonicalization)
|
|
617
|
+
|
|
618
|
+
Same columns as `sources.csv` minus `source_file` and `id`, plus:
|
|
619
|
+
|
|
620
|
+
| Column | Type | Notes |
|
|
621
|
+
| ------ | ---- | ----- |
|
|
622
|
+
| `citing_id` | string | the `w-…` id of the source work whose bibliography contained this citation |
|
|
623
|
+
|
|
624
|
+
A work cited by N papers appears N times here, with N different `citing_id`s.
|
|
625
|
+
|
|
626
|
+
### `works.csv` — canonical works, indexed by `id`
|
|
627
|
+
|
|
628
|
+
One row per canonical work: your own papers (ring 0) *and* everything they
|
|
629
|
+
cite, in one table. A citation that matches one of your own papers merges
|
|
630
|
+
into it rather than creating a separate row.
|
|
631
|
+
|
|
632
|
+
| Column | Type | Notes |
|
|
633
|
+
| ------ | ---- | ----- |
|
|
634
|
+
| `id` (index) | string | `w-<surname>-<year>-<title-slug>` |
|
|
635
|
+
| `ring` | int | discovery depth: `0` = your PDFs (the core), `1` = found in a ring-0 bibliography |
|
|
636
|
+
| `source_file` | string | original PDF filename; empty for works without full text |
|
|
637
|
+
| `Title`, `Authors`, `Journal`, `Year` | | from the first member of the cluster (full-text side wins) |
|
|
638
|
+
| `Journal_Canonical` | string | folded journal name to group on; `Journal` keeps the spelling the record used |
|
|
639
|
+
| `doi` | string | only after `--enrich`; `None` for unmatched rows |
|
|
640
|
+
| `enrichment_source` | string | only after `--enrich`; `"crossref"` or `"openalex"` |
|
|
641
|
+
| `enrichment_status` | string | only after `--enrich`; `"matched"` or `"miss"` |
|
|
642
|
+
| `enrichment_miss_reason` | string | only after `--enrich`; reason for unmatched rows, e.g. `"no_openalex_candidates"`, `"below_title_threshold"`, `"year_mismatch"`, `"author_mismatch"`, or `"http_error"` |
|
|
643
|
+
| `enrichment_title_score` | float | only after `--enrich`; raw fuzzy title score for the best accepted or rejected candidate |
|
|
644
|
+
| `enrichment_adjusted_score` | float | only after `--enrich`; title score after applying the year-mismatch penalty |
|
|
645
|
+
| `enrichment_candidate_title` | string | only after `--enrich`; external title that was accepted or explains the miss |
|
|
646
|
+
| `enrichment_year_match` | bool | only after `--enrich`; whether the input and candidate years agreed when both were known |
|
|
647
|
+
| `enrichment_year_delta` | int | only after `--enrich`; absolute year difference when both years were known |
|
|
648
|
+
|
|
649
|
+
`Authors_List` is preserved through canonicalization so author normalization can use structured author strings instead of comma-splitting display text. Enrichment (`--enrich`) writes the enriched copy to `enriched_works.csv` covering every ring — your core papers get DOIs and OpenAlex author ids too.
|
|
650
|
+
|
|
651
|
+
### `citation_graph.csv` — the actual graph
|
|
652
|
+
|
|
653
|
+
| Column | Type | Notes |
|
|
654
|
+
| ------ | ---- | ----- |
|
|
655
|
+
| `citing_id` | string | `w-…` (a work with full text) |
|
|
656
|
+
| `cited_id` | string | `w-…` (any work — including your own papers when cited within the corpus) |
|
|
657
|
+
|
|
658
|
+
### `authors.csv` — canonical authors, indexed by `id`
|
|
659
|
+
|
|
660
|
+
Produced by the author-normalization stage (`citegraph authors`, also part of
|
|
661
|
+
`citegraph run`). Sorted by `n_citations_received` (then `n_works`), descending.
|
|
662
|
+
|
|
663
|
+
| Column | Type | Notes |
|
|
664
|
+
| ------ | ---- | ----- |
|
|
665
|
+
| `id` (index) | string | `a-<surname>-<given>`, deterministic across runs |
|
|
666
|
+
| `display_name` | string | canonical human-readable name for the cluster |
|
|
667
|
+
| `surname` | string | as parsed from the best variant |
|
|
668
|
+
| `surname_norm` | string | diacritic-stripped surname used for blocking |
|
|
669
|
+
| `canonical_given` | string | longest full first name observed (or initials) |
|
|
670
|
+
| `initials` | string | canonical initials |
|
|
671
|
+
| `openalex_id` | string | OpenAlex author id when enrichment provided one |
|
|
672
|
+
| `orcid` | string | ORCID when enrichment provided one |
|
|
673
|
+
| `n_works` | int | distinct canonical works this author appears on |
|
|
674
|
+
| `n_core_works` | int | of those, how many are ring 0 (your own papers) |
|
|
675
|
+
| `n_citations_received` | int | citation edges into any of their works |
|
|
676
|
+
| `n_distinct_citing_works` | int | distinct works citing this author |
|
|
677
|
+
|
|
678
|
+
### `author_citations.csv` — one row per author occurrence
|
|
679
|
+
|
|
680
|
+
| Column | Type | Notes |
|
|
681
|
+
| ------ | ---- | ----- |
|
|
682
|
+
| `author_id` | string | the `a-…` cluster this occurrence was assigned to |
|
|
683
|
+
| `record_id` | string | the `w-…` work the name appeared on (join `works.ring` for the role) |
|
|
684
|
+
| `position` | int | author position on that work (0-based) |
|
|
685
|
+
| `raw_author` | string | the original author string before parsing |
|
|
686
|
+
|
|
687
|
+
### Curated files you may add beside the outputs
|
|
688
|
+
|
|
689
|
+
The generated CSVs are rebuilt by their stage; corrections go in these files,
|
|
690
|
+
which the stages read and apply last. Each fails loudly on an id that no longer
|
|
691
|
+
exists.
|
|
692
|
+
|
|
693
|
+
| File | Read by | Decides |
|
|
694
|
+
| ---- | ------- | ------- |
|
|
695
|
+
| `author_aliases.csv` (`cluster_id,canonical_id`) | `authors` | which author clusters are the same person |
|
|
696
|
+
| `author_external_ids.csv` (`author_id,openalex_id,orcid,note`) | `authors` | an author's identifier, outranking the provider's |
|
|
697
|
+
| `journal_aliases.csv` (`raw,canonical`) | `dedup`, `enrich` | journal abbreviations the automatic fold cannot match safely |
|
|
698
|
+
| `work_annotations.csv` (`id,…`) + `annotation_schema.csv` | `CitationGraph`, `jel` | anything a reviewer records about a work |
|
|
699
|
+
|
|
700
|
+
Low-confidence author clusters are flagged in `author_review.json`.
|
|
701
|
+
|
|
702
|
+
### Sidecar files
|
|
703
|
+
|
|
704
|
+
Most warning sidecar files follow the same convention: **absent means clean**.
|
|
705
|
+
They are created only when the condition they describe actually occurred, so
|
|
706
|
+
`path.exists()` is a sufficient check. Enrichment review files are different:
|
|
707
|
+
they are written when stage 5 runs so you can inspect match quality.
|
|
708
|
+
|
|
709
|
+
- `run_summary.json` — counts for the whole run: `n_works`, `n_core_works`, `n_citations_raw`, `n_edges`, `n_core_to_core_edges`, `n_self_loops_dropped`, `n_authors`, `n_author_citations`, `n_author_review_flags`, `n_metadata_failures`, `n_references_failures`, `n_source_duplicates`, `n_papers_no_references`, `n_conversion_warnings`, plus the model id and the dedup / author configuration.
|
|
710
|
+
- `artifact_manifest.json` — written by `citegraph run`: package version, stage, artifact paths, and the configuration used. Archive it with the CSVs for provenance.
|
|
711
|
+
- `metadata_failures.jsonl` and `references_failures.jsonl` — one JSON line per failed paper: `{source_file, stage, error_class, error_message}`. The pipeline keeps going past per-paper failures; re-running will retry them (their caches were not written).
|
|
712
|
+
- `source_duplicates.json` — written when two differently-named PDFs contain the same paper (detected by the same fuzzy-match logic used for reference deduplication). Each entry names the canonical source file, the duplicate file(s), and the paper title. The duplicate PDFs are silently skipped in the references stage; remove them from `pdf_dir` and re-run to clean up.
|
|
713
|
+
- `papers_no_references.json` — written when a paper is successfully processed but the LLM returned an empty reference list. Each entry has `paper_id`, `source_file`, and `title`. Common causes: image-only PDFs (see `--ocr-auto` / `--ocr`), papers with no bibliography section, or the model returning an empty list despite the content being present.
|
|
714
|
+
- `conversion_warnings.json` — written after stage 1 when any output markdown appears to be image-only or glyph-corrupted. Each entry has `source_file` and a human-readable `reason`. Re-run stage 1 with `--ocr-auto` to regenerate only the flagged files with OCR, or `--ocr` to force OCR for every PDF.
|
|
715
|
+
- `journal_alias_warnings.json` — written by `dedup` when a `journal_aliases.csv` row matches nothing *yet* because enrichment has not run; once every vocabulary exists a dead row raises instead.
|
|
716
|
+
- `enrichment_summary.json` — written by stage 5 when enrichment runs. Contains match/miss counts, source counts, match rate, and the `EnrichConfig` values used.
|
|
717
|
+
- `enrichment_misses.csv` — written by stage 5 when enrichment runs. Lists unmatched references with their miss reason so they can be inspected or curated later.
|
|
718
|
+
- `author_review.json` — written by the authors stage when a cluster looks low-confidence (initial-only name with several citations and no external id, a possible uncorroborated compound surname, or a detected corporate author). Inspect the flagged clusters and record decisions in `author_aliases.csv`.
|
|
719
|
+
|
|
720
|
+
## Evaluation
|
|
721
|
+
|
|
722
|
+
A small gold standard lives in [`evaluation/gold/`](evaluation/gold/). Run
|
|
723
|
+
|
|
724
|
+
```bash
|
|
725
|
+
python evaluation/evaluate.py --gold evaluation/gold --out ./out
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
to get metadata field-level accuracy, reference recall/precision, and
|
|
729
|
+
deduplication F1. The numbers in
|
|
730
|
+
[`evaluation/RESULTS.md`](evaluation/RESULTS.md) come from the synthetic
|
|
731
|
+
sample shipped with the repository and only confirm the harness is wired; they
|
|
732
|
+
are not a measurement of accuracy on a real corpus. Hand-label a few papers
|
|
733
|
+
from your own corpus before trusting any extraction rate.
|
|
734
|
+
|
|
735
|
+
## Configuration
|
|
736
|
+
|
|
737
|
+
`Pipeline(...)` arguments:
|
|
738
|
+
|
|
739
|
+
| Argument | Default | Meaning |
|
|
740
|
+
| -------- | ------- | ------- |
|
|
741
|
+
| `pdf_dir` | required | folder of input PDFs (`None` is allowed when resuming from a previous run's cache) |
|
|
742
|
+
| `out_dir` | required | where outputs and checkpoints are written |
|
|
743
|
+
| `model` | `gemini-3.1-flash-lite` | Gemini model id (or set `CITEGRAPH_MODEL`) |
|
|
744
|
+
| `enrich` | `False` | run a CrossRef/OpenAlex pass over the deduplicated references (requires the `[crossref]` or `[all]` extra) |
|
|
745
|
+
| `enrich_config` | `EnrichConfig()` | tune the enrichment pass — see below |
|
|
746
|
+
| `dedup_config` | `DedupConfig()` | tune the fuzzy dedup — see below |
|
|
747
|
+
| `author_config` | `AuthorClusterConfig()` | tune author clustering — see below |
|
|
748
|
+
| `llm_concurrency` | `4` | maximum concurrent Gemini extraction calls for metadata/references (or set `CITEGRAPH_LLM_CONCURRENCY`). CLI flag on LLM stages: `--llm-concurrency` |
|
|
749
|
+
| `overwrite_markdown` | `False` | re-run docling even when a cached `.md` exists (PDF conversion requires the `[pdf]` or `[all]` extra) |
|
|
750
|
+
| `recursive` | `False` | walk subdirectories of `pdf_dir`. Cache filenames are disambiguated by relative path (e.g. `journal_X/foo.pdf` → `journal_X__foo.md`); a clear error is raised if two PDFs would still collide. CLI flag: `--recursive` / `-r` |
|
|
751
|
+
| `ocr` | `False` | OCR mode: `False` disables OCR, `"auto"` retries image-only outputs with OCR, and `True` forces full-page OCR for every PDF. CLI flags: `--ocr-auto` / `--ocr` |
|
|
752
|
+
|
|
753
|
+
Dedup is tuned through `DedupConfig`:
|
|
754
|
+
|
|
755
|
+
```python
|
|
756
|
+
from citegraph import Pipeline
|
|
757
|
+
from citegraph.dedup import DedupConfig
|
|
758
|
+
|
|
759
|
+
pipe = Pipeline(
|
|
760
|
+
pdf_dir="./pdfs",
|
|
761
|
+
out_dir="./out",
|
|
762
|
+
dedup_config=DedupConfig(
|
|
763
|
+
threshold=85.0, # similarity threshold (0-100)
|
|
764
|
+
title_weight=0.7,
|
|
765
|
+
authors_weight=0.3,
|
|
766
|
+
journal_weight=0.0,
|
|
767
|
+
year_window=1, # max year difference for a match
|
|
768
|
+
),
|
|
769
|
+
)
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Enrichment is tuned through `EnrichConfig`:
|
|
773
|
+
|
|
774
|
+
```python
|
|
775
|
+
from citegraph.enrich import EnrichConfig
|
|
776
|
+
|
|
777
|
+
pipe = Pipeline(
|
|
778
|
+
pdf_dir="./pdfs",
|
|
779
|
+
out_dir="./out",
|
|
780
|
+
enrich=True,
|
|
781
|
+
enrich_config=EnrichConfig(
|
|
782
|
+
contact_email="you@example.com", # CrossRef polite-pool User-Agent
|
|
783
|
+
title_match_threshold=90.0,
|
|
784
|
+
timeout_s=15.0,
|
|
785
|
+
rows=3, # API result rows to consider
|
|
786
|
+
max_workers=8, # concurrent enrichment workers
|
|
787
|
+
year_mismatch_penalty=8.0, # subtract when candidate year differs
|
|
788
|
+
retry_attempts=3, # retry transient 429/503/timeouts
|
|
789
|
+
retry_wait_s=0.25, # exponential-backoff base wait
|
|
790
|
+
),
|
|
791
|
+
)
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
Author clustering is tuned through `AuthorClusterConfig`:
|
|
795
|
+
|
|
796
|
+
```python
|
|
797
|
+
from citegraph.authors import AuthorClusterConfig
|
|
798
|
+
|
|
799
|
+
pipe = Pipeline(
|
|
800
|
+
pdf_dir="./pdfs",
|
|
801
|
+
out_dir="./out",
|
|
802
|
+
author_config=AuthorClusterConfig(
|
|
803
|
+
merge_mode="strict", # precision-first (default); "loose" collapses
|
|
804
|
+
# by (surname, first-initial) regardless
|
|
805
|
+
),
|
|
806
|
+
)
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
On the CLI: `citegraph authors --merge-mode strict|loose`, plus `--aliases`
|
|
810
|
+
to point at a hand-curated `cluster_id,canonical_id` override CSV (defaults
|
|
811
|
+
to `<out>/author_aliases.csv` when it exists).
|
|
812
|
+
|
|
813
|
+
The CLI exposes the same knobs as flags — see `citegraph run --help`.
|
|
814
|
+
|
|
815
|
+
When enrichment runs, every row gets explicit diagnostics in
|
|
816
|
+
`enriched_works.csv`: `enrichment_status`, `enrichment_miss_reason`,
|
|
817
|
+
`enrichment_title_score`, `enrichment_adjusted_score`,
|
|
818
|
+
`enrichment_candidate_title`, `enrichment_year_match`, and
|
|
819
|
+
`enrichment_year_delta`. The same run writes `enrichment_summary.json` and
|
|
820
|
+
`enrichment_misses.csv` under `out_dir/` for quick review. The enrichment CLI
|
|
821
|
+
flags mirror these knobs: `--enrich-year-penalty`,
|
|
822
|
+
`--enrich-retry-attempts`, `--enrich-retry-wait`, and
|
|
823
|
+
`--enrich-max-workers`.
|
|
824
|
+
|
|
825
|
+
## Documentation
|
|
826
|
+
|
|
827
|
+
- [User guide](docs/USER_GUIDE.md) — staged tutorial, quality-control
|
|
828
|
+
checkpoints, output interpretation, privacy, costs, troubleshooting, and how
|
|
829
|
+
to correct and rerun.
|
|
830
|
+
- [Quickstart notebook](examples/quickstart.ipynb) — runnable tour of both
|
|
831
|
+
pipeline modes, the cost estimator, dedup and enrichment review, author
|
|
832
|
+
normalization, and the graph queries.
|
|
833
|
+
- [UI and application architecture](docs/UI_ARCHITECTURE.md) — proposed
|
|
834
|
+
boundary for a future researcher-facing interface.
|
|
835
|
+
- [Release checklist](docs/RELEASE.md) — packaging and publishing procedure.
|
|
836
|
+
- [Changelog](CHANGELOG.md) — release changes, migration, and known limitations.
|
|
837
|
+
|
|
838
|
+
## Development
|
|
839
|
+
|
|
840
|
+
```bash
|
|
841
|
+
pip install -e ".[dev]"
|
|
842
|
+
pytest
|
|
843
|
+
ruff check .
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
CI runs the same lint + test commands on Python 3.11 / 3.12 / 3.13; see
|
|
847
|
+
[.github/workflows/ci.yml](.github/workflows/ci.yml).
|
|
848
|
+
|
|
849
|
+
## Releasing
|
|
850
|
+
|
|
851
|
+
The release workflow ([.github/workflows/publish.yml](.github/workflows/publish.yml))
|
|
852
|
+
uses PyPI's [trusted publishing](https://docs.pypi.org/trusted-publishers/),
|
|
853
|
+
so no API token is stored in the repo. See [docs/RELEASE.md](docs/RELEASE.md)
|
|
854
|
+
for the full checklist. To cut a release:
|
|
855
|
+
|
|
856
|
+
1. Update the version and changelog as described in [docs/RELEASE.md](docs/RELEASE.md).
|
|
857
|
+
2. Commit and push to `main`.
|
|
858
|
+
3. `git tag v0.2.0 && git push origin v0.2.0` — the workflow then builds an
|
|
859
|
+
sdist + wheel and publishes them to PyPI.
|
|
860
|
+
|
|
861
|
+
To dry-run against TestPyPI first, build locally and upload manually:
|
|
862
|
+
|
|
863
|
+
```bash
|
|
864
|
+
python -m build
|
|
865
|
+
python -m twine upload --repository testpypi dist/*
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
## License
|
|
869
|
+
|
|
870
|
+
MIT — see [LICENSE](LICENSE).
|