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.
@@ -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
+ ![How an author co-citation network is built: each bibliography joins every pair of authors it cites, and the tie weight counts how many bibliographies do so](docs/figures/cocitation_build.svg)
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
+ ![citegraph extraction pipeline: PDFs are converted to markdown, an LLM extracts metadata and references as structured output, and canonicalization produces the works table and citation graph](docs/figures/pipeline.svg)
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).