citegraph 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- citegraph/__init__.py +44 -0
- citegraph/_progress.py +74 -0
- citegraph/annotations.py +183 -0
- citegraph/author_export.py +294 -0
- citegraph/author_overrides.py +143 -0
- citegraph/authors.py +1997 -0
- citegraph/cli.py +926 -0
- citegraph/cocitation_stats.py +370 -0
- citegraph/config.py +73 -0
- citegraph/cost_estimation.py +259 -0
- citegraph/dedup.py +498 -0
- citegraph/enrich.py +972 -0
- citegraph/extract_metadata.py +81 -0
- citegraph/extract_references.py +376 -0
- citegraph/graph.py +660 -0
- citegraph/html_report.py +1787 -0
- citegraph/identity.py +199 -0
- citegraph/ids.py +106 -0
- citegraph/io.py +566 -0
- citegraph/jel.py +167 -0
- citegraph/journals.py +182 -0
- citegraph/llm.py +186 -0
- citegraph/pdf_to_markdown.py +394 -0
- citegraph/pipeline.py +997 -0
- citegraph/plotting.py +1214 -0
- citegraph/reports.py +214 -0
- citegraph/schemas.py +89 -0
- citegraph/webui.py +624 -0
- citegraph-0.2.0.dist-info/METADATA +870 -0
- citegraph-0.2.0.dist-info/RECORD +33 -0
- citegraph-0.2.0.dist-info/WHEEL +4 -0
- citegraph-0.2.0.dist-info/entry_points.txt +2 -0
- citegraph-0.2.0.dist-info/licenses/LICENSE +21 -0
citegraph/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()
|