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/cli.py ADDED
@@ -0,0 +1,926 @@
1
+ """Command-line interface for citegraph.
2
+
3
+ Two ways to use it:
4
+
5
+ - ``citegraph run`` runs the full pipeline end-to-end.
6
+ - The per-stage commands (``convert``, ``metadata``, ``references``,
7
+ ``dedup``, ``enrich``) run one stage at a time, reading prior outputs
8
+ from ``--out``. ``citegraph status`` reports which artifacts exist,
9
+ ``citegraph report`` writes a self-contained ``report.html`` QC
10
+ dashboard summarizing whatever has been produced so far, and
11
+ ``citegraph ui`` serves a read-only live monitor that re-renders as a
12
+ run progresses.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from pathlib import Path
19
+
20
+ import pandas as pd
21
+ import typer
22
+ from rich.logging import RichHandler
23
+
24
+ from citegraph.authors import AuthorClusterConfig
25
+ from citegraph.config import get_settings
26
+ from citegraph.dedup import DedupConfig
27
+ from citegraph.enrich import EnrichConfig
28
+ from citegraph.io import CITATION_COLUMNS, OutLayout, read_stage_csv
29
+ from citegraph.pdf_to_markdown import OCRMode
30
+ from citegraph.pipeline import Pipeline, StageNotReadyError
31
+
32
+ app = typer.Typer(
33
+ add_completion=False,
34
+ help="Build a deduplicated citation graph from a folder of academic PDFs.",
35
+ )
36
+
37
+
38
+ def _configure_logging(verbose: bool) -> None:
39
+ level = logging.DEBUG if verbose else logging.INFO
40
+ logging.basicConfig(
41
+ level=level,
42
+ format="%(message)s",
43
+ datefmt="[%X]",
44
+ handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
45
+ force=True,
46
+ )
47
+
48
+
49
+ # Typer option defaults come from the config dataclass so the CLI and the
50
+ # library surface can't drift apart.
51
+ _DEDUP_DEFAULTS = DedupConfig()
52
+
53
+
54
+ def _dedup_config(
55
+ threshold: float,
56
+ title_weight: float,
57
+ authors_weight: float,
58
+ journal_weight: float,
59
+ year_window: int,
60
+ ) -> DedupConfig:
61
+ return DedupConfig(
62
+ title_weight=title_weight,
63
+ authors_weight=authors_weight,
64
+ journal_weight=journal_weight,
65
+ year_window=year_window,
66
+ threshold=threshold,
67
+ )
68
+
69
+
70
+ def _enrich_config(
71
+ contact: str,
72
+ threshold: float,
73
+ timeout: float,
74
+ year_penalty: float,
75
+ retry_attempts: int,
76
+ retry_wait: float,
77
+ max_workers: int,
78
+ openalex_api_key: str = "",
79
+ author_name_fuzz: float = 85.0,
80
+ ) -> EnrichConfig:
81
+ if not openalex_api_key:
82
+ openalex_api_key = get_settings().openalex_api_key or ""
83
+ return EnrichConfig(
84
+ contact_email=contact,
85
+ openalex_api_key=openalex_api_key,
86
+ title_match_threshold=threshold,
87
+ timeout_s=timeout,
88
+ year_mismatch_penalty=year_penalty,
89
+ retry_attempts=retry_attempts,
90
+ retry_wait_s=retry_wait,
91
+ max_workers=max_workers,
92
+ author_name_fuzz=author_name_fuzz,
93
+ )
94
+
95
+
96
+ def _warn_conversion_quality(layout: OutLayout, *, ocr_attempted: bool) -> None:
97
+ """Echo a yellow warning when stage 1 left image-only outputs.
98
+
99
+ Tailors the remediation hint to whether OCR was already tried — telling
100
+ the user to "re-run with --ocr" is unhelpful when they just did that.
101
+ """
102
+ if not layout.conversion_warnings_json.exists():
103
+ return
104
+ import json as _json
105
+
106
+ warns = _json.loads(layout.conversion_warnings_json.read_text(encoding="utf-8"))
107
+ if ocr_attempted:
108
+ msg = (
109
+ f"{len(warns)} file(s) appear image-only even after OCR; "
110
+ "manual review needed. See:"
111
+ )
112
+ else:
113
+ msg = (
114
+ f"{len(warns)} file(s) appear image-only (scanned PDFs?). "
115
+ "Re-run with --ocr-auto (fastest) or --ocr (force). See:"
116
+ )
117
+ typer.secho(msg, fg=typer.colors.YELLOW)
118
+ typer.secho(f" {layout.conversion_warnings_json}", fg=typer.colors.YELLOW)
119
+
120
+
121
+ def _warn_unused_journal_aliases(layout: OutLayout) -> None:
122
+ """Echo a yellow warning for curated journal aliases that folded nothing.
123
+
124
+ Only reachable when enrichment is configured but has not run yet: once
125
+ every vocabulary is on the table a dead row raises instead.
126
+ """
127
+ from citegraph.reports import count_journal_alias_warnings
128
+
129
+ n = count_journal_alias_warnings(layout.journal_alias_warnings_json)
130
+ if not n:
131
+ return
132
+ typer.secho(
133
+ f"{n} journal alias(es) fold nothing yet; run `citegraph enrich` and "
134
+ "re-check before trusting a journal rollup. See:",
135
+ fg=typer.colors.YELLOW,
136
+ )
137
+ typer.secho(f" {layout.journal_alias_warnings_json}", fg=typer.colors.YELLOW)
138
+
139
+
140
+ def _resolve_ocr_mode(ocr: bool, ocr_auto: bool) -> OCRMode:
141
+ """Map the two mutually-exclusive CLI flags to the Pipeline ``ocr`` value."""
142
+ if ocr and ocr_auto:
143
+ typer.secho(
144
+ "--ocr and --ocr-auto are mutually exclusive; pick one.",
145
+ fg=typer.colors.RED,
146
+ err=True,
147
+ )
148
+ raise typer.Exit(code=2)
149
+ if ocr_auto:
150
+ return "auto"
151
+ return ocr
152
+
153
+
154
+ def _next_step_hint(msg: str) -> None:
155
+ typer.echo(f"Next: {msg}")
156
+
157
+
158
+ def _run_stage(fn, *, next_hint: str | None) -> None:
159
+ try:
160
+ fn()
161
+ except StageNotReadyError as e:
162
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
163
+ raise typer.Exit(code=1) from e
164
+ if next_hint:
165
+ _next_step_hint(next_hint)
166
+
167
+
168
+ @app.command()
169
+ def run(
170
+ pdf_dir: Path = typer.Argument(..., exists=True, file_okay=False, dir_okay=True),
171
+ out: Path = typer.Option(Path("./out"), "--out", "-o", help="Output directory."),
172
+ enrich: bool = typer.Option(False, "--enrich", help="Resolve DOIs via CrossRef/OpenAlex."),
173
+ enrich_contact: str = typer.Option("", "--enrich-contact", help="Contact email for CrossRef polite pool."),
174
+ enrich_threshold: float = typer.Option(90.0, "--enrich-threshold", help="Title match threshold for enrichment."),
175
+ enrich_timeout: float = typer.Option(15.0, "--enrich-timeout", help="HTTP timeout in seconds for enrichment."),
176
+ enrich_year_penalty: float = typer.Option(
177
+ 8.0,
178
+ "--enrich-year-penalty",
179
+ help="Score penalty applied when input and candidate years differ.",
180
+ ),
181
+ enrich_author_fuzz: float = typer.Option(
182
+ 85.0,
183
+ "--enrich-author-fuzz",
184
+ help="Similarity at which two author names count as the same person. "
185
+ "A candidate sharing no author with the extracted record is rejected.",
186
+ ),
187
+ enrich_retry_attempts: int = typer.Option(
188
+ 3,
189
+ "--enrich-retry-attempts",
190
+ min=1,
191
+ help="Attempts for transient enrichment HTTP errors such as 429/503.",
192
+ ),
193
+ enrich_retry_wait: float = typer.Option(
194
+ 0.25,
195
+ "--enrich-retry-wait",
196
+ min=0.0,
197
+ help="Base exponential-backoff wait in seconds for enrichment retries.",
198
+ ),
199
+ enrich_max_workers: int = typer.Option(
200
+ 8,
201
+ "--enrich-max-workers",
202
+ min=1,
203
+ help="Maximum concurrent CrossRef/OpenAlex enrichment lookups.",
204
+ ),
205
+ openalex_api_key: str = typer.Option(
206
+ "",
207
+ "--openalex-api-key",
208
+ help="OpenAlex API key (default: OPENALEX_API_KEY env var / .env).",
209
+ ),
210
+ model: str | None = typer.Option(None, "--model", help="Gemini model id."),
211
+ threshold: float = typer.Option(
212
+ _DEDUP_DEFAULTS.threshold, "--threshold", help="Dedup similarity threshold."
213
+ ),
214
+ title_weight: float = typer.Option(_DEDUP_DEFAULTS.title_weight, "--title-weight"),
215
+ authors_weight: float = typer.Option(_DEDUP_DEFAULTS.authors_weight, "--authors-weight"),
216
+ journal_weight: float = typer.Option(_DEDUP_DEFAULTS.journal_weight, "--journal-weight"),
217
+ year_window: int = typer.Option(_DEDUP_DEFAULTS.year_window, "--year-window"),
218
+ llm_concurrency: int | None = typer.Option(
219
+ None,
220
+ "--llm-concurrency",
221
+ min=1,
222
+ help="Maximum concurrent Gemini extraction calls (default: CITEGRAPH_LLM_CONCURRENCY or 4).",
223
+ ),
224
+ overwrite_markdown: bool = typer.Option(
225
+ False, "--overwrite-markdown", help="Re-run docling even if cached."
226
+ ),
227
+ recursive: bool = typer.Option(
228
+ False,
229
+ "--recursive",
230
+ "-r",
231
+ help="Walk subdirectories of PDF_DIR. Cache keys are disambiguated by relative path.",
232
+ ),
233
+ ocr: bool = typer.Option(
234
+ False, "--ocr", help="Force full-page OCR via EasyOCR for every PDF (for scanned PDFs)."
235
+ ),
236
+ ocr_auto: bool = typer.Option(
237
+ False,
238
+ "--ocr-auto",
239
+ help=(
240
+ "Two-pass OCR: convert without OCR first, then re-run image-only outputs with OCR. "
241
+ "Cheaper than --ocr when most PDFs have selectable text."
242
+ ),
243
+ ),
244
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip cost-estimate confirmation."),
245
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
246
+ ) -> None:
247
+ """Run the full PDF -> citation graph pipeline."""
248
+ _configure_logging(verbose)
249
+ cfg = _dedup_config(threshold, title_weight, authors_weight, journal_weight, year_window)
250
+ ecfg = _enrich_config(
251
+ enrich_contact,
252
+ enrich_threshold,
253
+ enrich_timeout,
254
+ enrich_year_penalty,
255
+ enrich_retry_attempts,
256
+ enrich_retry_wait,
257
+ enrich_max_workers,
258
+ openalex_api_key,
259
+ enrich_author_fuzz,
260
+ )
261
+ ocr_mode = _resolve_ocr_mode(ocr, ocr_auto)
262
+ pipeline = Pipeline(
263
+ pdf_dir=pdf_dir,
264
+ out_dir=out,
265
+ model=model,
266
+ enrich=enrich,
267
+ enrich_config=ecfg,
268
+ dedup_config=cfg,
269
+ overwrite_markdown=overwrite_markdown,
270
+ recursive=recursive,
271
+ ocr=ocr_mode,
272
+ llm_concurrency=llm_concurrency,
273
+ )
274
+
275
+ markdown_paths = None
276
+ if not yes:
277
+ # Convert PDFs first so we can estimate token cost before any LLM calls.
278
+ markdown_paths = pipeline.convert_pdfs()
279
+ est = pipeline.estimate_extraction_cost()
280
+ typer.echo("\nCost estimate for LLM extraction stages:\n")
281
+ typer.echo(est.format_summary())
282
+ typer.echo()
283
+ if not typer.confirm("Proceed with extraction?", default=True):
284
+ typer.echo("Aborted.")
285
+ raise typer.Exit(code=0)
286
+ typer.echo()
287
+
288
+ result = pipeline.run(markdown_paths=markdown_paths)
289
+ n_core = (
290
+ int((result.works["ring"] == 0).sum())
291
+ if "ring" in result.works.columns
292
+ else 0
293
+ )
294
+ typer.echo(
295
+ f"Done. {n_core} core works, "
296
+ f"{len(result.works)} works total, "
297
+ f"{len(result.graph)} citation edges."
298
+ )
299
+ layout = pipeline.layout
300
+ _warn_conversion_quality(layout, ocr_attempted=bool(ocr_mode))
301
+ _warn_unused_journal_aliases(layout)
302
+ if layout.papers_no_references_json.exists():
303
+ import json as _json
304
+ entries = _json.loads(layout.papers_no_references_json.read_text(encoding="utf-8"))
305
+ typer.secho(
306
+ f"{len(entries)} paper(s) yielded no references (image-only or unreadable PDF?). See:",
307
+ fg=typer.colors.YELLOW,
308
+ )
309
+ typer.secho(f" {layout.papers_no_references_json}", fg=typer.colors.YELLOW)
310
+ if layout.source_duplicates_json.exists():
311
+ import json as _json
312
+ groups = _json.loads(layout.source_duplicates_json.read_text(encoding="utf-8"))
313
+ n_dup = sum(len(g.get("duplicate_source_files", [])) for g in groups)
314
+ typer.secho(
315
+ f"{n_dup} markdown file(s) are duplicate PDFs; remove the originals and re-run. See:",
316
+ fg=typer.colors.YELLOW,
317
+ )
318
+ typer.secho(f" {layout.source_duplicates_json}", fg=typer.colors.YELLOW)
319
+ if layout.metadata_failures_jsonl.exists() or layout.references_failures_jsonl.exists():
320
+ typer.secho(
321
+ "Some papers failed; re-running will retry them. See:",
322
+ fg=typer.colors.YELLOW,
323
+ )
324
+ for p in (layout.metadata_failures_jsonl, layout.references_failures_jsonl):
325
+ if p.exists():
326
+ typer.secho(f" {p}", fg=typer.colors.YELLOW)
327
+ typer.echo(f"Outputs written under: {out.resolve()}")
328
+
329
+
330
+ @app.command()
331
+ def estimate(
332
+ out: Path = typer.Option(Path("./out"), "--out", "-o"),
333
+ model: str | None = typer.Option(None, "--model", help="Gemini model id."),
334
+ ) -> None:
335
+ """Estimate the LLM token cost of extracting metadata and references.
336
+
337
+ Reads existing markdown files from ``out/markdown/`` (run
338
+ ``citegraph convert`` first) and reports how many tokens stages 2 and 3
339
+ would consume, broken down by cached vs. uncached files.
340
+ """
341
+ pipeline = Pipeline(pdf_dir=None, out_dir=out, model=model)
342
+ try:
343
+ est = pipeline.estimate_extraction_cost()
344
+ except StageNotReadyError as e:
345
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
346
+ raise typer.Exit(code=1) from e
347
+ typer.echo(est.format_summary())
348
+
349
+
350
+ @app.command()
351
+ def convert(
352
+ pdf_dir: Path = typer.Argument(..., exists=True, file_okay=False, dir_okay=True),
353
+ out: Path = typer.Option(Path("./out"), "--out", "-o"),
354
+ overwrite_markdown: bool = typer.Option(
355
+ False, "--overwrite-markdown", help="Re-run docling even if cached."
356
+ ),
357
+ recursive: bool = typer.Option(
358
+ False,
359
+ "--recursive",
360
+ "-r",
361
+ help="Walk subdirectories of PDF_DIR. Cache keys are disambiguated by relative path.",
362
+ ),
363
+ ocr: bool = typer.Option(
364
+ False, "--ocr", help="Force full-page OCR via EasyOCR for every PDF (for scanned PDFs)."
365
+ ),
366
+ ocr_auto: bool = typer.Option(
367
+ False,
368
+ "--ocr-auto",
369
+ help=(
370
+ "Two-pass OCR: convert without OCR first, then re-run image-only outputs with OCR. "
371
+ "Cheaper than --ocr when most PDFs have selectable text."
372
+ ),
373
+ ),
374
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
375
+ ) -> None:
376
+ """Stage 1: convert PDFs to markdown via docling."""
377
+ _configure_logging(verbose)
378
+ ocr_mode = _resolve_ocr_mode(ocr, ocr_auto)
379
+ pipeline = Pipeline(
380
+ pdf_dir=pdf_dir,
381
+ out_dir=out,
382
+ overwrite_markdown=overwrite_markdown,
383
+ recursive=recursive,
384
+ ocr=ocr_mode,
385
+ )
386
+ paths = pipeline.convert_pdfs()
387
+ typer.echo(f"Converted {len(paths)} PDFs. Markdown in {pipeline.layout.markdown_dir}.")
388
+ _warn_conversion_quality(pipeline.layout, ocr_attempted=bool(ocr_mode))
389
+ _next_step_hint(f"`citegraph metadata --out {out}` (inspect markdown/ first if you want).")
390
+
391
+
392
+ @app.command()
393
+ def metadata(
394
+ out: Path = typer.Option(Path("./out"), "--out", "-o"),
395
+ model: str | None = typer.Option(None, "--model"),
396
+ llm_concurrency: int | None = typer.Option(
397
+ None,
398
+ "--llm-concurrency",
399
+ min=1,
400
+ help="Maximum concurrent Gemini metadata extraction calls.",
401
+ ),
402
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
403
+ ) -> None:
404
+ """Stage 2: extract per-paper metadata from cached markdown."""
405
+ _configure_logging(verbose)
406
+ pipeline = Pipeline(
407
+ pdf_dir=None,
408
+ out_dir=out,
409
+ model=model,
410
+ llm_concurrency=llm_concurrency,
411
+ )
412
+
413
+ def _go() -> None:
414
+ df = pipeline.extract_paper_metadata()
415
+ typer.echo(f"Wrote {pipeline.layout.sources_csv} ({len(df)} sources).")
416
+ if pipeline.layout.source_duplicates_json.exists():
417
+ import json as _json
418
+ groups = _json.loads(
419
+ pipeline.layout.source_duplicates_json.read_text(encoding="utf-8")
420
+ )
421
+ n_dup = sum(len(g.get("duplicate_source_files", [])) for g in groups)
422
+ typer.secho(
423
+ f"{n_dup} markdown file(s) appear to be duplicate PDFs. See "
424
+ f"{pipeline.layout.source_duplicates_json}",
425
+ fg=typer.colors.YELLOW,
426
+ )
427
+
428
+ _run_stage(_go, next_hint=f"`citegraph references --out {out}`.")
429
+
430
+
431
+ @app.command()
432
+ def references(
433
+ out: Path = typer.Option(Path("./out"), "--out", "-o"),
434
+ model: str | None = typer.Option(None, "--model"),
435
+ llm_concurrency: int | None = typer.Option(
436
+ None,
437
+ "--llm-concurrency",
438
+ min=1,
439
+ help="Maximum concurrent Gemini reference extraction calls.",
440
+ ),
441
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip cost-estimate confirmation."),
442
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
443
+ ) -> None:
444
+ """Stage 3: extract per-paper reference lists from cached markdown."""
445
+ _configure_logging(verbose)
446
+ pipeline = Pipeline(
447
+ pdf_dir=None,
448
+ out_dir=out,
449
+ model=model,
450
+ llm_concurrency=llm_concurrency,
451
+ )
452
+
453
+ if not yes:
454
+ try:
455
+ est = pipeline.estimate_extraction_cost()
456
+ except StageNotReadyError as e:
457
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
458
+ raise typer.Exit(code=1) from e
459
+ if est.n_references_to_process > 0:
460
+ typer.echo("\nCost estimate for references extraction:\n")
461
+ typer.echo(est.format_references_summary())
462
+ typer.echo()
463
+ if not typer.confirm("Proceed with references extraction?", default=True):
464
+ typer.echo("Aborted.")
465
+ raise typer.Exit(code=0)
466
+ typer.echo()
467
+
468
+ def _go() -> None:
469
+ df = pipeline.extract_paper_references()
470
+ typer.echo(f"Wrote {pipeline.layout.citations_raw_csv} ({len(df)} raw citations).")
471
+ if pipeline.layout.papers_no_references_json.exists():
472
+ import json as _json
473
+ entries = _json.loads(
474
+ pipeline.layout.papers_no_references_json.read_text(encoding="utf-8")
475
+ )
476
+ typer.secho(
477
+ f"{len(entries)} paper(s) yielded no references. See "
478
+ f"{pipeline.layout.papers_no_references_json}",
479
+ fg=typer.colors.YELLOW,
480
+ )
481
+
482
+ _run_stage(_go, next_hint=f"`citegraph dedup --out {out}`.")
483
+
484
+
485
+ @app.command()
486
+ def dedup(
487
+ citations_raw_csv: Path | None = typer.Argument(
488
+ None,
489
+ exists=True,
490
+ dir_okay=False,
491
+ help="Path to citations_raw.csv. Defaults to <out>/citations_raw.csv.",
492
+ ),
493
+ out: Path = typer.Option(Path("./out"), "--out", "-o"),
494
+ threshold: float = typer.Option(_DEDUP_DEFAULTS.threshold, "--threshold"),
495
+ title_weight: float = typer.Option(_DEDUP_DEFAULTS.title_weight, "--title-weight"),
496
+ authors_weight: float = typer.Option(_DEDUP_DEFAULTS.authors_weight, "--authors-weight"),
497
+ journal_weight: float = typer.Option(_DEDUP_DEFAULTS.journal_weight, "--journal-weight"),
498
+ year_window: int = typer.Option(_DEDUP_DEFAULTS.year_window, "--year-window"),
499
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
500
+ ) -> None:
501
+ """Stage 4: canonicalize sources + citations into works and the citation graph."""
502
+ _configure_logging(verbose)
503
+ layout = OutLayout(out)
504
+ layout.ensure()
505
+ cfg = _dedup_config(threshold, title_weight, authors_weight, journal_weight, year_window)
506
+
507
+ citations_raw: pd.DataFrame | None = None
508
+ if citations_raw_csv is not None:
509
+ citations_raw = read_stage_csv(citations_raw_csv, columns=CITATION_COLUMNS)
510
+
511
+ pipeline = Pipeline(pdf_dir=None, out_dir=out, dedup_config=cfg)
512
+ try:
513
+ works, graph = pipeline.deduplicate(citations_raw=citations_raw)
514
+ except StageNotReadyError as e:
515
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
516
+ raise typer.Exit(code=1) from e
517
+ except ValueError as e:
518
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
519
+ raise typer.Exit(code=1) from e
520
+
521
+ n_core = int((works["ring"] == 0).sum())
522
+ typer.echo(
523
+ f"Canonicalized into {len(works)} works ({n_core} core, "
524
+ f"{len(graph)} edges). Wrote {layout.works_csv} and {layout.graph_csv}."
525
+ )
526
+ _warn_unused_journal_aliases(layout)
527
+ _next_step_hint(
528
+ f"Inspect works.csv. Optionally `citegraph enrich --out {out}` for DOI lookup."
529
+ )
530
+
531
+
532
+ @app.command()
533
+ def enrich(
534
+ out: Path = typer.Option(Path("./out"), "--out", "-o"),
535
+ enrich_contact: str = typer.Option("", "--enrich-contact", help="Contact email for CrossRef polite pool."),
536
+ enrich_threshold: float = typer.Option(90.0, "--enrich-threshold", help="Title match threshold."),
537
+ enrich_timeout: float = typer.Option(15.0, "--enrich-timeout", help="HTTP timeout in seconds."),
538
+ enrich_year_penalty: float = typer.Option(
539
+ 8.0,
540
+ "--enrich-year-penalty",
541
+ help="Score penalty applied when input and candidate years differ.",
542
+ ),
543
+ enrich_author_fuzz: float = typer.Option(
544
+ 85.0,
545
+ "--enrich-author-fuzz",
546
+ help="Similarity at which two author names count as the same person. "
547
+ "A candidate sharing no author with the extracted record is rejected.",
548
+ ),
549
+ enrich_retry_attempts: int = typer.Option(
550
+ 3,
551
+ "--enrich-retry-attempts",
552
+ min=1,
553
+ help="Attempts for transient enrichment HTTP errors such as 429/503.",
554
+ ),
555
+ enrich_retry_wait: float = typer.Option(
556
+ 0.25,
557
+ "--enrich-retry-wait",
558
+ min=0.0,
559
+ help="Base exponential-backoff wait in seconds for enrichment retries.",
560
+ ),
561
+ enrich_max_workers: int = typer.Option(
562
+ 8,
563
+ "--enrich-max-workers",
564
+ min=1,
565
+ help="Maximum concurrent CrossRef/OpenAlex enrichment lookups.",
566
+ ),
567
+ openalex_api_key: str = typer.Option(
568
+ "",
569
+ "--openalex-api-key",
570
+ help="OpenAlex API key (default: OPENALEX_API_KEY env var / .env).",
571
+ ),
572
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
573
+ ) -> None:
574
+ """Stage 5: optional CrossRef/OpenAlex enrichment of references.csv."""
575
+ _configure_logging(verbose)
576
+ ecfg = _enrich_config(
577
+ enrich_contact,
578
+ enrich_threshold,
579
+ enrich_timeout,
580
+ enrich_year_penalty,
581
+ enrich_retry_attempts,
582
+ enrich_retry_wait,
583
+ enrich_max_workers,
584
+ openalex_api_key,
585
+ enrich_author_fuzz,
586
+ )
587
+ pipeline = Pipeline(pdf_dir=None, out_dir=out, enrich=True, enrich_config=ecfg)
588
+
589
+ def _go() -> None:
590
+ df = pipeline.maybe_enrich()
591
+ typer.echo(
592
+ f"Enriched {len(df)} works → {pipeline.layout.enriched_works_csv}."
593
+ )
594
+
595
+ _run_stage(_go, next_hint=None)
596
+
597
+
598
+ @app.command()
599
+ def authors(
600
+ out: Path = typer.Option(Path("./out"), "--out", "-o"),
601
+ merge_mode: str = typer.Option(
602
+ "strict",
603
+ "--merge-mode",
604
+ help="'strict' (precision-first, default) or 'loose' (collapse by surname+first-initial).",
605
+ ),
606
+ aliases: Path | None = typer.Option(
607
+ None,
608
+ "--aliases",
609
+ help="Optional CSV of hand-curated overrides: cluster_id,canonical_id. "
610
+ "Defaults to <out>/author_aliases.csv when it exists.",
611
+ ),
612
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
613
+ ) -> None:
614
+ """Stage 4b: cluster authors across the corpus into canonical records.
615
+
616
+ Reads ``works.csv`` from ``--out`` and writes ``authors.csv`` +
617
+ ``author_citations.csv``. When ``enriched_works.csv`` is present,
618
+ OpenAlex / ORCID ids are used as ground truth for identity.
619
+ """
620
+ _configure_logging(verbose)
621
+ if merge_mode not in {"strict", "loose"}:
622
+ typer.secho(
623
+ f"--merge-mode must be 'strict' or 'loose', got {merge_mode!r}.",
624
+ fg=typer.colors.RED, err=True,
625
+ )
626
+ raise typer.Exit(code=1)
627
+
628
+ pipeline = Pipeline(
629
+ pdf_dir=None,
630
+ out_dir=out,
631
+ author_config=AuthorClusterConfig(merge_mode=merge_mode),
632
+ )
633
+ if aliases is not None:
634
+ # User-specified path overrides the default. Copy the file in
635
+ # under the canonical name so `Pipeline.normalize_authors` finds
636
+ # it via the layout, without forcing a new code path.
637
+ import shutil
638
+ if aliases.exists():
639
+ shutil.copy(aliases, pipeline.layout.author_aliases_csv)
640
+ else:
641
+ typer.secho(
642
+ f"--aliases path does not exist: {aliases}",
643
+ fg=typer.colors.RED, err=True,
644
+ )
645
+ raise typer.Exit(code=1)
646
+
647
+ def _go() -> None:
648
+ layout = pipeline.layout
649
+ if not layout.enriched_works_csv.exists():
650
+ n_cached = layout.enrichment_cache_count()
651
+ if n_cached:
652
+ typer.secho(
653
+ f"{n_cached} cached enrichment result(s) found in "
654
+ f"{layout.enrichment_dir} but enriched_works.csv is "
655
+ "missing. Run `citegraph enrich` first so OpenAlex/ORCID "
656
+ "ids anchor author clustering.",
657
+ fg=typer.colors.YELLOW,
658
+ )
659
+ authors_df, citations_df = pipeline.normalize_authors()
660
+ typer.echo(
661
+ f"Clustered {len(citations_df)} author occurrences into "
662
+ f"{len(authors_df)} canonical authors. "
663
+ f"Wrote {pipeline.layout.authors_csv}."
664
+ )
665
+ if pipeline.layout.author_review_json.exists():
666
+ import json as _json
667
+ review = _json.loads(
668
+ pipeline.layout.author_review_json.read_text(encoding="utf-8")
669
+ )
670
+ typer.secho(
671
+ f"{len(review)} cluster(s) flagged for review. See "
672
+ f"{pipeline.layout.author_review_json}",
673
+ fg=typer.colors.YELLOW,
674
+ )
675
+
676
+ _run_stage(_go, next_hint=None)
677
+
678
+
679
+ @app.command()
680
+ def report(
681
+ out: Path = typer.Option(Path("./out"), "--out", "-o"),
682
+ open_browser: bool = typer.Option(
683
+ False, "--open", help="Open the written report.html in a web browser."
684
+ ),
685
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
686
+ ) -> None:
687
+ """Write a self-contained QC dashboard (``report.html``) into ``--out``.
688
+
689
+ Reads whatever artifacts exist — it is safe (and useful) to re-run
690
+ after every pipeline stage. No LLM or network calls are made.
691
+ """
692
+ _configure_logging(verbose)
693
+ if not out.is_dir():
694
+ typer.secho(
695
+ f"Output directory does not exist: {out}. "
696
+ f"Run `citegraph convert <pdf_dir> --out {out}` first.",
697
+ fg=typer.colors.RED,
698
+ err=True,
699
+ )
700
+ raise typer.Exit(code=1)
701
+
702
+ from citegraph.html_report import collect_report_data, write_report
703
+
704
+ data = collect_report_data(out)
705
+ path = write_report(out, data=data)
706
+ typer.echo(f"Wrote {path}.")
707
+ n_problems = data.get("problems", {}).get("total", 0)
708
+ if n_problems:
709
+ typer.secho(
710
+ f"{n_problems} problem(s) flagged — open the report for details.",
711
+ fg=typer.colors.YELLOW,
712
+ )
713
+ if open_browser:
714
+ import webbrowser
715
+
716
+ webbrowser.open(path.resolve().as_uri())
717
+
718
+
719
+ @app.command()
720
+ def jel(
721
+ out: Path = typer.Option(Path("./out"), "--out", "-o"),
722
+ ) -> None:
723
+ """Write ``jel_codes.csv``: the JEL codes of every core paper.
724
+
725
+ Codes are read from the converted markdown; papers whose PDF carries none
726
+ take them from the ``JEL_Codes`` column of ``work_annotations.csv``. No
727
+ LLM or network calls are made.
728
+ """
729
+ from citegraph.jel import core_jel_codes, write_jel_codes
730
+
731
+ frame = core_jel_codes(out)
732
+ path = write_jel_codes(out)
733
+ n_core = len(frame)
734
+ counts = frame["JEL_Source"].value_counts()
735
+ typer.echo(f"Wrote {path}.")
736
+ typer.echo(
737
+ f"{counts.get('extracted', 0)} of {n_core} core papers carry JEL codes in their "
738
+ f"text; {counts.get('manual', 0)} hand-coded in work_annotations.csv; "
739
+ f"{counts.get('missing', 0)} without any."
740
+ )
741
+ missing = frame[frame["JEL_Source"] == "missing"]
742
+ if len(missing):
743
+ typer.secho(
744
+ "Add a JEL_Codes column to work_annotations.csv for:", fg=typer.colors.YELLOW
745
+ )
746
+ for work_id, row in missing.iterrows():
747
+ typer.echo(f" {work_id} {row['Title']}")
748
+
749
+
750
+ @app.command("export-authors")
751
+ def export_authors(
752
+ out: Path = typer.Option(Path("./out"), "--out", "-o"),
753
+ dest: Path | None = typer.Option(
754
+ None, "--dest", help="Write the workbook here instead of <out>/core_authors.xlsx."
755
+ ),
756
+ ring: int = typer.Option(
757
+ 0, "--ring", help="Discovery ring whose authors to export (0 = your own PDFs)."
758
+ ),
759
+ extra_csv: list[str] = typer.Option(
760
+ None,
761
+ "--extra-csv",
762
+ metavar="NAME=PATH",
763
+ help=(
764
+ "Add a curated CSV as a sheet, filtered to these authors. Rows are kept "
765
+ "when any 'author_id' or '*_author_id' column names a core author. Repeatable."
766
+ ),
767
+ ),
768
+ master_column: list[str] = typer.Option(
769
+ None,
770
+ "--master-column",
771
+ metavar="SHEET=COLUMN",
772
+ help=(
773
+ "Lift a column from an --extra-csv sheet onto the Authors sheet. "
774
+ "The sheet must have one row per author. Repeatable."
775
+ ),
776
+ ),
777
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
778
+ ) -> None:
779
+ """Write ``core_authors.xlsx`` — everything known about your papers' authors.
780
+
781
+ Reads only artifacts already on disk; no LLM or network calls. Needs the
782
+ author tables, so run ``citegraph authors`` first.
783
+ """
784
+ _configure_logging(verbose)
785
+ if not out.is_dir():
786
+ typer.secho(
787
+ f"Output directory does not exist: {out}. "
788
+ f"Run `citegraph authors --out {out}` first.",
789
+ fg=typer.colors.RED,
790
+ err=True,
791
+ )
792
+ raise typer.Exit(code=1)
793
+
794
+ from citegraph.author_export import build_core_author_workbook, load_extra_sheet
795
+
796
+ try:
797
+ lifts = _parse_pairs(master_column or [], "--master-column")
798
+ sources = _parse_pairs(extra_csv or [], "--extra-csv")
799
+ repeated = sorted(name for name, paths in sources.items() if len(paths) > 1)
800
+ if repeated:
801
+ raise ValueError(
802
+ f"--extra-csv names sheet(s) {repeated} more than once; "
803
+ "each sheet comes from one file."
804
+ )
805
+ sheets = tuple(
806
+ load_extra_sheet(name, paths[0], master_columns=tuple(lifts.pop(name, [])))
807
+ for name, paths in sources.items()
808
+ )
809
+ if lifts:
810
+ raise ValueError(
811
+ f"--master-column names sheet(s) {sorted(lifts)} that no --extra-csv "
812
+ "provides; check the spelling."
813
+ )
814
+ path = build_core_author_workbook(out, dest, extra_sheets=sheets, ring=ring)
815
+ except (ValueError, ImportError) as exc:
816
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
817
+ raise typer.Exit(code=1) from exc
818
+
819
+ import pandas as pd
820
+
821
+ n_authors = len(pd.read_excel(path, sheet_name="Authors"))
822
+ typer.echo(f"Wrote {path} ({n_authors} ring-{ring} author(s)).")
823
+ for sheet in sheets:
824
+ covered = sum(
825
+ 1
826
+ for value in pd.read_excel(path, sheet_name="Authors")[f"in_{sheet.name}"]
827
+ if bool(value)
828
+ )
829
+ if covered < n_authors:
830
+ typer.secho(
831
+ f"{sheet.name} covers {covered} of {n_authors} authors — "
832
+ "the rest have blank columns.",
833
+ fg=typer.colors.YELLOW,
834
+ )
835
+
836
+
837
+ def _parse_pairs(values: list[str], flag: str) -> dict[str, list[str]]:
838
+ """Parse repeated ``NAME=VALUE`` options, keeping every value per name."""
839
+ parsed: dict[str, list[str]] = {}
840
+ for value in values:
841
+ name, separator, rest = value.partition("=")
842
+ if not separator or not name.strip() or not rest.strip():
843
+ raise ValueError(f"{flag} expects NAME=VALUE, got {value!r}")
844
+ parsed.setdefault(name.strip(), []).append(rest.strip())
845
+ return parsed
846
+
847
+
848
+ @app.command()
849
+ def ui(
850
+ out: Path = typer.Option(Path("./out"), "--out", "-o"),
851
+ port: int = typer.Option(8765, "--port", help="Port to serve on (binds 127.0.0.1 only)."),
852
+ open_browser: bool = typer.Option(
853
+ False, "--open", help="Open the live monitor in a web browser."
854
+ ),
855
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
856
+ ) -> None:
857
+ """Serve a read-only live monitor for ``--out`` on localhost.
858
+
859
+ Polls the out directory as pipeline stages run and re-renders stage
860
+ progress, problems, recent file activity, and the per-paper table.
861
+ It never writes into ``--out`` and works before the directory exists
862
+ (the page fills in as artifacts appear). Ctrl+C stops it.
863
+ """
864
+ _configure_logging(verbose)
865
+ from citegraph.webui import create_server
866
+
867
+ server = create_server(out, port=port, verbose=verbose)
868
+ url = f"http://127.0.0.1:{server.server_address[1]}/"
869
+ typer.echo(f"citegraph live monitor: {url} (watching {out}; Ctrl+C to stop)")
870
+ if not out.is_dir():
871
+ typer.secho(
872
+ f"{out} does not exist yet — the page will fill in once a stage writes to it.",
873
+ fg=typer.colors.YELLOW,
874
+ )
875
+ if open_browser:
876
+ import webbrowser
877
+
878
+ webbrowser.open(url)
879
+ try:
880
+ server.serve_forever()
881
+ except KeyboardInterrupt:
882
+ typer.echo("\nStopped.")
883
+ finally:
884
+ server.server_close()
885
+
886
+
887
+ @app.command()
888
+ def status(
889
+ out: Path = typer.Option(Path("./out"), "--out", "-o"),
890
+ ) -> None:
891
+ """Report which pipeline artifacts exist under ``out``."""
892
+ layout = OutLayout(out)
893
+
894
+ md_count = (
895
+ len(list(layout.markdown_dir.glob("*.md"))) if layout.markdown_dir.exists() else 0
896
+ )
897
+ meta_count = (
898
+ len(list(layout.metadata_dir.glob("*.json"))) if layout.metadata_dir.exists() else 0
899
+ )
900
+ ref_count = (
901
+ len(list(layout.references_dir.glob("*.json"))) if layout.references_dir.exists() else 0
902
+ )
903
+
904
+ def _rows(path: Path) -> str:
905
+ if not path.exists():
906
+ return "missing"
907
+ try:
908
+ return f"{len(pd.read_csv(path))} rows"
909
+ except Exception as e: # pragma: no cover - defensive
910
+ return f"unreadable ({e})"
911
+
912
+ typer.echo(f"out_dir: {out.resolve()}")
913
+ typer.echo(f" markdown/ {md_count} files")
914
+ typer.echo(f" metadata/ {meta_count} cached")
915
+ typer.echo(f" references/ {ref_count} cached")
916
+ typer.echo(f" sources.csv {_rows(layout.sources_csv)}")
917
+ typer.echo(f" citations_raw.csv {_rows(layout.citations_raw_csv)}")
918
+ typer.echo(f" works.csv {_rows(layout.works_csv)}")
919
+ typer.echo(f" enriched_works.csv {_rows(layout.enriched_works_csv)}")
920
+ typer.echo(f" citation_graph.csv {_rows(layout.graph_csv)}")
921
+ typer.echo(f" authors.csv {_rows(layout.authors_csv)}")
922
+ typer.echo(f" author_citations.csv {_rows(layout.author_citations_csv)}")
923
+
924
+
925
+ if __name__ == "__main__": # pragma: no cover
926
+ app()