citegraph 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- citegraph/__init__.py +44 -0
- citegraph/_progress.py +74 -0
- citegraph/annotations.py +183 -0
- citegraph/author_export.py +294 -0
- citegraph/author_overrides.py +143 -0
- citegraph/authors.py +1997 -0
- citegraph/cli.py +926 -0
- citegraph/cocitation_stats.py +370 -0
- citegraph/config.py +73 -0
- citegraph/cost_estimation.py +259 -0
- citegraph/dedup.py +498 -0
- citegraph/enrich.py +972 -0
- citegraph/extract_metadata.py +81 -0
- citegraph/extract_references.py +376 -0
- citegraph/graph.py +660 -0
- citegraph/html_report.py +1787 -0
- citegraph/identity.py +199 -0
- citegraph/ids.py +106 -0
- citegraph/io.py +566 -0
- citegraph/jel.py +167 -0
- citegraph/journals.py +182 -0
- citegraph/llm.py +186 -0
- citegraph/pdf_to_markdown.py +394 -0
- citegraph/pipeline.py +997 -0
- citegraph/plotting.py +1214 -0
- citegraph/reports.py +214 -0
- citegraph/schemas.py +89 -0
- citegraph/webui.py +624 -0
- citegraph-0.2.0.dist-info/METADATA +870 -0
- citegraph-0.2.0.dist-info/RECORD +33 -0
- citegraph-0.2.0.dist-info/WHEEL +4 -0
- citegraph-0.2.0.dist-info/entry_points.txt +2 -0
- citegraph-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Does a documented tie between two authors show up in how the literature cites them?
|
|
2
|
+
|
|
3
|
+
A supervision record says that two people are connected socially. An author
|
|
4
|
+
co-citation network says which people the field reads together. This module
|
|
5
|
+
asks whether the first shows up in the second: for each tie, is the pair
|
|
6
|
+
co-cited more often than a *comparable* pair of strangers?
|
|
7
|
+
|
|
8
|
+
"Comparable" is the whole problem. Advisors and their students are prominent
|
|
9
|
+
people, and prominent people are co-cited with everyone, so a tie that beats an
|
|
10
|
+
average pair has proved nothing. The null here therefore holds prominence
|
|
11
|
+
fixed: each end of a tie is replaced by an author cited by a similar number of
|
|
12
|
+
bibliographies, and the tie is scored against the pairs that substitution
|
|
13
|
+
produces. What survives is co-citation the two ends' individual visibility does
|
|
14
|
+
not already explain.
|
|
15
|
+
|
|
16
|
+
Two further controls matter on a corpus whose bibliographies were written by
|
|
17
|
+
the people being studied:
|
|
18
|
+
|
|
19
|
+
- ``exclude_self_citations`` drops, for each pair, the bibliographies either
|
|
20
|
+
member wrote. What remains is co-citation by third parties — other people
|
|
21
|
+
putting the two names in one conversation. **The exclusion defines a universe
|
|
22
|
+
rather than filtering one side of a comparison**: the tie's remaining
|
|
23
|
+
bibliographies become the eligible set, and the null pairs are then counted,
|
|
24
|
+
and prominence-matched, inside that same set. Filtering only the tie would
|
|
25
|
+
compare authors who lost most of their chances against authors who lost none
|
|
26
|
+
— on the corpus this was written for, 485 of 564 co-cited authors wrote none
|
|
27
|
+
of the bibliographies at all, while one end of a tie wrote every bibliography
|
|
28
|
+
that cited them. That comparison is not conservative, it is wrong.
|
|
29
|
+
- Combined inference needs independent p-values. :func:`fisher_combined_p` is
|
|
30
|
+
retained as an assumption-bound diagnostic, but supervision ties that share
|
|
31
|
+
people are dependent, so it is not confirmatory evidence for overlapping ties.
|
|
32
|
+
|
|
33
|
+
Nothing here is Gemini- or provider-specific: it reads the finished tables.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import math
|
|
39
|
+
import random
|
|
40
|
+
from collections.abc import Iterable, Sequence
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
|
|
43
|
+
import pandas as pd
|
|
44
|
+
|
|
45
|
+
from citegraph.graph import CitationGraph, author_mentions
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"TieCocitation",
|
|
49
|
+
"SkippedTie",
|
|
50
|
+
"TieCocitationTest",
|
|
51
|
+
"tie_cocitation_test",
|
|
52
|
+
"fisher_combined_p",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
#: Columns of :meth:`TieCocitationTest.to_frame`, in order.
|
|
56
|
+
TIE_COLUMNS = [
|
|
57
|
+
"source",
|
|
58
|
+
"target",
|
|
59
|
+
"source_name",
|
|
60
|
+
"target_name",
|
|
61
|
+
"observed",
|
|
62
|
+
"null_median",
|
|
63
|
+
"null_mean",
|
|
64
|
+
"p_value",
|
|
65
|
+
"pool_size",
|
|
66
|
+
"n_eligible",
|
|
67
|
+
"n_draws",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class TieCocitation:
|
|
73
|
+
"""One tie scored against its degree-matched null.
|
|
74
|
+
|
|
75
|
+
``observed`` is the number of eligible bibliographies citing both ends.
|
|
76
|
+
``null_median`` / ``null_mean`` summarise the same count over pairs drawn
|
|
77
|
+
from the two ends' prominence strata, and ``p_value`` is the share of those
|
|
78
|
+
draws reaching ``observed`` — counting the observation itself, so it can
|
|
79
|
+
never be reported as zero. ``pool_size`` is the smaller of the two strata:
|
|
80
|
+
a tie scored against a handful of comparable authors carries a p-value with
|
|
81
|
+
very few distinct values, whatever the draw count says.
|
|
82
|
+
|
|
83
|
+
``n_eligible`` is how many bibliographies the tie was scored over. It
|
|
84
|
+
equals the corpus total unless self-citations were excluded, and then it is
|
|
85
|
+
the number that remained — the honest denominator, and the thing to look at
|
|
86
|
+
before reading anything into a tie that came out at zero.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
source: str
|
|
90
|
+
target: str
|
|
91
|
+
source_name: str
|
|
92
|
+
target_name: str
|
|
93
|
+
observed: int
|
|
94
|
+
null_median: float
|
|
95
|
+
null_mean: float
|
|
96
|
+
p_value: float
|
|
97
|
+
pool_size: int
|
|
98
|
+
n_eligible: int
|
|
99
|
+
n_draws: int
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class SkippedTie:
|
|
104
|
+
"""A tie the data cannot carry, kept so a caller can report the shortfall."""
|
|
105
|
+
|
|
106
|
+
source: str
|
|
107
|
+
target: str
|
|
108
|
+
reason: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class TieCocitationTest:
|
|
113
|
+
"""Every tie that could be scored, every tie that could not, and the totals."""
|
|
114
|
+
|
|
115
|
+
ties: tuple[TieCocitation, ...]
|
|
116
|
+
skipped: tuple[SkippedTie, ...]
|
|
117
|
+
n_citing_works: int
|
|
118
|
+
n_network_authors: int
|
|
119
|
+
n_draws: int
|
|
120
|
+
alpha: float
|
|
121
|
+
exclude_self_citations: bool
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def n_ties(self) -> int:
|
|
125
|
+
return len(self.ties)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def n_significant(self) -> int:
|
|
129
|
+
"""Ties whose own permutation p-value falls below ``alpha``."""
|
|
130
|
+
return sum(1 for tie in self.ties if tie.p_value < self.alpha)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def expected_significant(self) -> float:
|
|
134
|
+
"""How many of that count chance alone would be expected to produce."""
|
|
135
|
+
return self.alpha * self.n_ties
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def fisher_p_value(self) -> float | None:
|
|
139
|
+
"""Fisher's combination of the per-tie p-values.
|
|
140
|
+
|
|
141
|
+
This is only interpretable when the supplied ties are independent.
|
|
142
|
+
Overlapping supervision ties violate that assumption, so callers must
|
|
143
|
+
treat this as a diagnostic rather than confirmatory evidence.
|
|
144
|
+
"""
|
|
145
|
+
return fisher_combined_p([tie.p_value for tie in self.ties])
|
|
146
|
+
|
|
147
|
+
def to_frame(self) -> pd.DataFrame:
|
|
148
|
+
"""One row per scored tie, in the order the ties were supplied."""
|
|
149
|
+
if not self.ties:
|
|
150
|
+
return pd.DataFrame(columns=TIE_COLUMNS)
|
|
151
|
+
return pd.DataFrame(
|
|
152
|
+
[{column: getattr(tie, column) for column in TIE_COLUMNS} for tie in self.ties]
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def tie_cocitation_test(
|
|
157
|
+
graph: CitationGraph,
|
|
158
|
+
ties: Iterable[tuple[str, str]],
|
|
159
|
+
*,
|
|
160
|
+
min_papers: int = 3,
|
|
161
|
+
n_draws: int = 2000,
|
|
162
|
+
degree_tolerance: float = 0.2,
|
|
163
|
+
exclude_self_citations: bool = False,
|
|
164
|
+
alpha: float = 0.05,
|
|
165
|
+
seed: int = 0,
|
|
166
|
+
) -> TieCocitationTest:
|
|
167
|
+
"""Score each ``(source, target)`` author pair against a degree-matched null.
|
|
168
|
+
|
|
169
|
+
``min_papers`` is the co-citation network's inclusion threshold and must
|
|
170
|
+
match the network a companion figure draws, or the test and the picture
|
|
171
|
+
describe different populations. ``degree_tolerance`` is the half-width of
|
|
172
|
+
the prominence stratum a substitute is drawn from, as a fraction of the
|
|
173
|
+
replaced author's citing-bibliography count, with a floor of one
|
|
174
|
+
bibliography — at a threshold of three, a 20% band would otherwise admit
|
|
175
|
+
only exact matches.
|
|
176
|
+
|
|
177
|
+
Each tie seeds its own generator from its own identity, so the numbers a
|
|
178
|
+
tie gets do not depend on where it sat in ``ties`` or on how many ties came
|
|
179
|
+
with it. A pair given twice, or given both ways round, is scored once.
|
|
180
|
+
"""
|
|
181
|
+
graph._require_authors()
|
|
182
|
+
if n_draws < 1:
|
|
183
|
+
raise ValueError(f"n_draws must be at least 1, got {n_draws}")
|
|
184
|
+
if not 0 < alpha < 1:
|
|
185
|
+
raise ValueError(f"alpha must lie in (0, 1), got {alpha}")
|
|
186
|
+
|
|
187
|
+
mentions = author_mentions(graph.edges, graph.author_citations, min_papers=min_papers)
|
|
188
|
+
cited_by: dict[str, frozenset[str]] = {
|
|
189
|
+
author: frozenset(group)
|
|
190
|
+
for author, group in mentions.groupby("author_id")["citing_id"]
|
|
191
|
+
}
|
|
192
|
+
universe = sorted(cited_by)
|
|
193
|
+
|
|
194
|
+
citing_works = set(graph.edges["citing_id"].unique())
|
|
195
|
+
wrote = graph.author_citations[graph.author_citations["record_id"].isin(citing_works)]
|
|
196
|
+
authored_by: dict[str, frozenset[str]] = {
|
|
197
|
+
author: frozenset(group) for author, group in wrote.groupby("author_id")["record_id"]
|
|
198
|
+
}
|
|
199
|
+
names = graph.authors["display_name"] if "display_name" in graph.authors.columns else None
|
|
200
|
+
|
|
201
|
+
def display(author: str) -> str:
|
|
202
|
+
if names is None:
|
|
203
|
+
return author
|
|
204
|
+
return str(names.get(author, author))
|
|
205
|
+
|
|
206
|
+
def stratum(
|
|
207
|
+
author: str, exclude: Sequence[str], local_counts: dict[str, int]
|
|
208
|
+
) -> list[str]:
|
|
209
|
+
width = max(1.0, degree_tolerance * local_counts[author])
|
|
210
|
+
blocked = set(exclude)
|
|
211
|
+
return [
|
|
212
|
+
other
|
|
213
|
+
for other in universe
|
|
214
|
+
if other not in blocked
|
|
215
|
+
and abs(local_counts[other] - local_counts[author]) <= width
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
scored: list[TieCocitation] = []
|
|
219
|
+
skipped: list[SkippedTie] = []
|
|
220
|
+
seen: set[frozenset[str]] = set()
|
|
221
|
+
|
|
222
|
+
for source, target in ties:
|
|
223
|
+
if source == target:
|
|
224
|
+
skipped.append(SkippedTie(source, target, "both ends are the same author"))
|
|
225
|
+
continue
|
|
226
|
+
key = frozenset((source, target))
|
|
227
|
+
if key in seen:
|
|
228
|
+
continue
|
|
229
|
+
missing = [end for end in (source, target) if end not in cited_by]
|
|
230
|
+
if missing:
|
|
231
|
+
skipped.append(
|
|
232
|
+
SkippedTie(
|
|
233
|
+
source,
|
|
234
|
+
target,
|
|
235
|
+
f"not in the co-citation network at min_papers={min_papers}: "
|
|
236
|
+
+ ", ".join(missing),
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
continue
|
|
240
|
+
seen.add(key)
|
|
241
|
+
|
|
242
|
+
# The eligible bibliographies are this tie's universe. Every count
|
|
243
|
+
# below — the tie's, the strata's, the null draws' — is taken inside
|
|
244
|
+
# it, so the tie and its comparison pairs had the same chances.
|
|
245
|
+
if exclude_self_citations:
|
|
246
|
+
eligible = (
|
|
247
|
+
citing_works
|
|
248
|
+
- authored_by.get(source, frozenset())
|
|
249
|
+
- authored_by.get(target, frozenset())
|
|
250
|
+
)
|
|
251
|
+
local_cited = {author: works & eligible for author, works in cited_by.items()}
|
|
252
|
+
else:
|
|
253
|
+
eligible = citing_works
|
|
254
|
+
local_cited = cited_by
|
|
255
|
+
local_counts = {author: len(works) for author, works in local_cited.items()}
|
|
256
|
+
|
|
257
|
+
def shared(first: str, second: str, _cited: dict[str, frozenset[str]] = local_cited) -> int:
|
|
258
|
+
return len(_cited[first] & _cited[second])
|
|
259
|
+
|
|
260
|
+
source_pool = stratum(source, (source, target), local_counts)
|
|
261
|
+
target_pool = stratum(target, (source, target), local_counts)
|
|
262
|
+
empty_ends = [
|
|
263
|
+
author
|
|
264
|
+
for author, pool in ((source, source_pool), (target, target_pool))
|
|
265
|
+
if not pool
|
|
266
|
+
]
|
|
267
|
+
if empty_ends:
|
|
268
|
+
skipped.append(
|
|
269
|
+
SkippedTie(
|
|
270
|
+
source,
|
|
271
|
+
target,
|
|
272
|
+
"no degree-matched substitute for: " + ", ".join(empty_ends),
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
continue
|
|
276
|
+
if len(set(source_pool) | set(target_pool)) < 2:
|
|
277
|
+
skipped.append(
|
|
278
|
+
SkippedTie(source, target, "no comparable pair of authors to draw a null from")
|
|
279
|
+
)
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
observed = shared(source, target)
|
|
283
|
+
low, high = sorted((source, target))
|
|
284
|
+
rng = random.Random(f"{seed}|{low}|{high}")
|
|
285
|
+
draws: list[int] = []
|
|
286
|
+
# Two draws can collide on one author; redraw rather than score a
|
|
287
|
+
# self-pair, and cap the attempts so a degenerate stratum cannot spin.
|
|
288
|
+
attempts = 0
|
|
289
|
+
while len(draws) < n_draws and attempts < n_draws * 20:
|
|
290
|
+
attempts += 1
|
|
291
|
+
first = rng.choice(source_pool)
|
|
292
|
+
second = rng.choice(target_pool)
|
|
293
|
+
if first == second:
|
|
294
|
+
continue
|
|
295
|
+
draws.append(shared(first, second))
|
|
296
|
+
if not draws: # pragma: no cover - guarded by the stratum size check
|
|
297
|
+
skipped.append(SkippedTie(source, target, "null sampling produced no valid pair"))
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
at_least = sum(1 for value in draws if value >= observed)
|
|
301
|
+
scored.append(
|
|
302
|
+
TieCocitation(
|
|
303
|
+
source=source,
|
|
304
|
+
target=target,
|
|
305
|
+
source_name=display(source),
|
|
306
|
+
target_name=display(target),
|
|
307
|
+
observed=observed,
|
|
308
|
+
null_median=float(_median(draws)),
|
|
309
|
+
null_mean=float(sum(draws) / len(draws)),
|
|
310
|
+
p_value=(1 + at_least) / (1 + len(draws)),
|
|
311
|
+
pool_size=min(len(source_pool), len(target_pool)),
|
|
312
|
+
n_eligible=len(eligible),
|
|
313
|
+
n_draws=len(draws),
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
return TieCocitationTest(
|
|
318
|
+
ties=tuple(scored),
|
|
319
|
+
skipped=tuple(skipped),
|
|
320
|
+
n_citing_works=len(citing_works),
|
|
321
|
+
n_network_authors=len(universe),
|
|
322
|
+
n_draws=n_draws,
|
|
323
|
+
alpha=alpha,
|
|
324
|
+
exclude_self_citations=exclude_self_citations,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _median(values: Sequence[float]) -> float:
|
|
329
|
+
ordered = sorted(values)
|
|
330
|
+
middle = len(ordered) // 2
|
|
331
|
+
if len(ordered) % 2:
|
|
332
|
+
return float(ordered[middle])
|
|
333
|
+
return (ordered[middle - 1] + ordered[middle]) / 2
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ----------------------------------------------------------------------
|
|
337
|
+
# Combination statistic
|
|
338
|
+
# ----------------------------------------------------------------------
|
|
339
|
+
def fisher_combined_p(pvalues: Iterable[float]) -> float | None:
|
|
340
|
+
"""Combine independent p-values by Fisher's method.
|
|
341
|
+
|
|
342
|
+
``-2 * sum(log p)`` is chi-squared on twice as many degrees of freedom.
|
|
343
|
+
The degrees of freedom are always even, so the survival function has the
|
|
344
|
+
closed form used here and no SciPy dependency is needed for it.
|
|
345
|
+
|
|
346
|
+
Returns ``None`` for an empty input. **The independence assumption is
|
|
347
|
+
real**: supervision ties that share an advisor are not independent, and
|
|
348
|
+
this will overstate the evidence for them.
|
|
349
|
+
"""
|
|
350
|
+
values = [float(p) for p in pvalues]
|
|
351
|
+
if not values:
|
|
352
|
+
return None
|
|
353
|
+
for value in values:
|
|
354
|
+
if not 0.0 <= value <= 1.0:
|
|
355
|
+
raise ValueError(f"p-values must be between 0 and 1, got {value}")
|
|
356
|
+
if any(value == 0.0 for value in values):
|
|
357
|
+
return 0.0
|
|
358
|
+
statistic = -2.0 * sum(math.log(value) for value in values)
|
|
359
|
+
return _chi2_sf_even(statistic, 2 * len(values))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _chi2_sf_even(statistic: float, df: int) -> float:
|
|
363
|
+
"""``P(X > statistic)`` for chi-squared with an even number of df, exactly."""
|
|
364
|
+
half = statistic / 2.0
|
|
365
|
+
term = 1.0
|
|
366
|
+
total = 1.0
|
|
367
|
+
for i in range(1, df // 2):
|
|
368
|
+
term *= half / i
|
|
369
|
+
total += term
|
|
370
|
+
return math.exp(-half) * total
|
citegraph/config.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Runtime configuration for citegraph.
|
|
2
|
+
|
|
3
|
+
Reads from environment variables and an optional ``.env`` file via
|
|
4
|
+
``pydantic-settings``. No file paths or secrets should be hardcoded
|
|
5
|
+
elsewhere in the package.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Settings(BaseSettings):
|
|
17
|
+
"""Global, environment-driven settings."""
|
|
18
|
+
|
|
19
|
+
model_config = SettingsConfigDict(
|
|
20
|
+
env_file=".env",
|
|
21
|
+
env_file_encoding="utf-8",
|
|
22
|
+
env_prefix="",
|
|
23
|
+
extra="ignore",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
google_api_key: str | None = Field(
|
|
27
|
+
default=None,
|
|
28
|
+
validation_alias="GOOGLE_API_KEY",
|
|
29
|
+
description="API key for the Google Gemini API.",
|
|
30
|
+
)
|
|
31
|
+
openalex_api_key: str | None = Field(
|
|
32
|
+
default=None,
|
|
33
|
+
validation_alias="OPENALEX_API_KEY",
|
|
34
|
+
description="API key for the OpenAlex API (raises the daily credit budget).",
|
|
35
|
+
)
|
|
36
|
+
citegraph_model: str = Field(
|
|
37
|
+
default="gemini-3.1-flash-lite",
|
|
38
|
+
validation_alias="CITEGRAPH_MODEL",
|
|
39
|
+
description="Default Gemini model identifier.",
|
|
40
|
+
)
|
|
41
|
+
citegraph_max_output_tokens: int = Field(
|
|
42
|
+
default=40_000,
|
|
43
|
+
validation_alias="CITEGRAPH_MAX_OUTPUT_TOKENS",
|
|
44
|
+
)
|
|
45
|
+
citegraph_request_timeout_s: float = Field(
|
|
46
|
+
default=120.0,
|
|
47
|
+
validation_alias="CITEGRAPH_REQUEST_TIMEOUT_S",
|
|
48
|
+
)
|
|
49
|
+
citegraph_llm_concurrency: int = Field(
|
|
50
|
+
default=4,
|
|
51
|
+
ge=1,
|
|
52
|
+
validation_alias="CITEGRAPH_LLM_CONCURRENCY",
|
|
53
|
+
description="Maximum concurrent LLM extraction calls.",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def require_api_key(self) -> str:
|
|
57
|
+
"""Return the Gemini API key or raise a helpful error."""
|
|
58
|
+
if not self.google_api_key:
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
"GOOGLE_API_KEY is not set. Export it in your environment or "
|
|
61
|
+
"place it in a .env file in your working directory."
|
|
62
|
+
)
|
|
63
|
+
return self.google_api_key
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_settings() -> Settings:
|
|
67
|
+
"""Load settings (cheap; safe to call repeatedly)."""
|
|
68
|
+
return Settings()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def default_out_dir() -> Path:
|
|
72
|
+
"""Default output directory used when the caller doesn't specify one."""
|
|
73
|
+
return Path.cwd() / "out"
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Pre-flight cost estimation for the LLM extraction stages.
|
|
2
|
+
|
|
3
|
+
Reads the on-disk markdown files (stage-1 output) and estimates Gemini token
|
|
4
|
+
usage for stages 2 (metadata) and 3 (references) before making any API calls.
|
|
5
|
+
Already-cached files are excluded from the estimate.
|
|
6
|
+
|
|
7
|
+
Library usage::
|
|
8
|
+
|
|
9
|
+
pipeline = Pipeline(pdf_dir=..., out_dir=...)
|
|
10
|
+
estimate = pipeline.estimate_extraction_cost()
|
|
11
|
+
print(estimate.format_summary())
|
|
12
|
+
|
|
13
|
+
CLI usage::
|
|
14
|
+
|
|
15
|
+
citegraph estimate --out ./out
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
from citegraph.extract_metadata import DEFAULT_METADATA_INPUT_CHARS
|
|
24
|
+
from citegraph.extract_references import slice_to_references_section
|
|
25
|
+
from citegraph.io import OutLayout, read_pydantic_cache
|
|
26
|
+
from citegraph.schemas import PaperMetadata, Reference
|
|
27
|
+
|
|
28
|
+
# Rough heuristic: English/academic prose averages ~4 characters per token.
|
|
29
|
+
_CHARS_PER_TOKEN: int = 4
|
|
30
|
+
|
|
31
|
+
# Static prompt overhead per call (template chars + system instruction chars).
|
|
32
|
+
# Measured from the actual prompt strings in extract_metadata.py / extract_references.py.
|
|
33
|
+
_METADATA_OVERHEAD_CHARS: int = 560
|
|
34
|
+
_REFERENCES_OVERHEAD_CHARS: int = 1_060
|
|
35
|
+
|
|
36
|
+
# Output-token constants for cost projection.
|
|
37
|
+
_METADATA_OUTPUT_TOKENS: int = 300 # PaperMetadata JSON is small and bounded
|
|
38
|
+
_TOKENS_PER_REFERENCE: int = 75 # one Reference JSON object
|
|
39
|
+
_CHARS_PER_REFERENCE_APPROX: int = 175 # typical reference length in source text
|
|
40
|
+
|
|
41
|
+
# Pricing table: (input USD / 1M tokens, output USD / 1M tokens).
|
|
42
|
+
# Approximate standard paid-tier rates as of 2026-09-04 — check
|
|
43
|
+
# https://ai.google.dev/gemini-api/docs/pricing for
|
|
44
|
+
# current numbers before trusting these for budget planning.
|
|
45
|
+
_PRICING: dict[str, tuple[float, float]] = {
|
|
46
|
+
"gemini-2.5-pro-preview": (1.25, 10.00),
|
|
47
|
+
"gemini-2.5-flash-preview": (0.15, 0.60),
|
|
48
|
+
"gemini-2.5-flash": (0.15, 0.60),
|
|
49
|
+
"gemini-2.0-flash": (0.10, 0.40),
|
|
50
|
+
"gemini-2.0-flash-lite": (0.075, 0.30),
|
|
51
|
+
"gemini-1.5-pro": (1.25, 5.00),
|
|
52
|
+
"gemini-1.5-flash": (0.075, 0.30),
|
|
53
|
+
"gemini-1.5-flash-lite": (0.075, 0.30),
|
|
54
|
+
"gemini-3.1-flash-lite": (0.25, 1.50),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _chars_to_tokens(n: int) -> int:
|
|
59
|
+
return math.ceil(n / _CHARS_PER_TOKEN)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class FileEstimate:
|
|
64
|
+
"""Per-file breakdown of estimated token usage."""
|
|
65
|
+
|
|
66
|
+
name: str
|
|
67
|
+
metadata_cached: bool
|
|
68
|
+
references_cached: bool
|
|
69
|
+
metadata_input_tokens: int # 0 when cached
|
|
70
|
+
references_input_tokens: int # 0 when cached
|
|
71
|
+
estimated_n_references: int # 0 when cached
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ExtractionEstimate:
|
|
76
|
+
"""Aggregate token and cost estimate for LLM extraction stages 2 and 3."""
|
|
77
|
+
|
|
78
|
+
model: str
|
|
79
|
+
files: list[FileEstimate] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def n_files(self) -> int:
|
|
83
|
+
return len(self.files)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def n_metadata_to_process(self) -> int:
|
|
87
|
+
return sum(1 for f in self.files if not f.metadata_cached)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def n_references_to_process(self) -> int:
|
|
91
|
+
return sum(1 for f in self.files if not f.references_cached)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def total_input_tokens(self) -> int:
|
|
95
|
+
return sum(f.metadata_input_tokens + f.references_input_tokens for f in self.files)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def estimated_output_tokens(self) -> int:
|
|
99
|
+
meta_out = self.n_metadata_to_process * _METADATA_OUTPUT_TOKENS
|
|
100
|
+
refs_out = sum(
|
|
101
|
+
f.estimated_n_references * _TOKENS_PER_REFERENCE
|
|
102
|
+
for f in self.files
|
|
103
|
+
if not f.references_cached
|
|
104
|
+
)
|
|
105
|
+
return meta_out + refs_out
|
|
106
|
+
|
|
107
|
+
def cost_usd(self) -> tuple[float, float] | None:
|
|
108
|
+
"""Return ``(input_cost_usd, output_cost_usd)``, or ``None`` for unknown models."""
|
|
109
|
+
pricing = _PRICING.get(self.model)
|
|
110
|
+
if pricing is None:
|
|
111
|
+
return None
|
|
112
|
+
in_price, out_price = pricing
|
|
113
|
+
return (
|
|
114
|
+
self.total_input_tokens * in_price / 1_000_000,
|
|
115
|
+
self.estimated_output_tokens * out_price / 1_000_000,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def format_references_summary(self) -> str:
|
|
119
|
+
"""Return a concise cost summary covering only stage 3 (references extraction)."""
|
|
120
|
+
n_cached = self.n_files - self.n_references_to_process
|
|
121
|
+
cached_str = f" ({n_cached} cached)" if n_cached else ""
|
|
122
|
+
|
|
123
|
+
refs_input = sum(f.references_input_tokens for f in self.files)
|
|
124
|
+
refs_output = sum(
|
|
125
|
+
f.estimated_n_references * _TOKENS_PER_REFERENCE
|
|
126
|
+
for f in self.files
|
|
127
|
+
if not f.references_cached
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
lines = [
|
|
131
|
+
f"Model: {self.model}",
|
|
132
|
+
f"Files to process: {self.n_references_to_process}{cached_str}",
|
|
133
|
+
f"Est. input tokens: ~{refs_input:,}",
|
|
134
|
+
f"Est. output tokens: ~{refs_output:,}",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
pricing = _PRICING.get(self.model)
|
|
138
|
+
if pricing is not None:
|
|
139
|
+
in_price, out_price = pricing
|
|
140
|
+
in_cost = refs_input * in_price / 1_000_000
|
|
141
|
+
out_cost = refs_output * out_price / 1_000_000
|
|
142
|
+
lines.append(
|
|
143
|
+
f"Est. cost: ~${in_cost:.4f} input"
|
|
144
|
+
f" + ~${out_cost:.4f} output"
|
|
145
|
+
f" = ~${in_cost + out_cost:.4f} total"
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
lines.append(
|
|
149
|
+
f"Est. cost: unknown (no pricing data for '{self.model}')"
|
|
150
|
+
)
|
|
151
|
+
lines.append("(Estimates are approximate; actual cost depends on response length.)")
|
|
152
|
+
return "\n".join(lines)
|
|
153
|
+
|
|
154
|
+
def format_summary(self) -> str:
|
|
155
|
+
"""Return a multi-line human-readable cost summary."""
|
|
156
|
+
n_meta_cached = self.n_files - self.n_metadata_to_process
|
|
157
|
+
n_refs_cached = self.n_files - self.n_references_to_process
|
|
158
|
+
|
|
159
|
+
def _cached(n: int) -> str:
|
|
160
|
+
return f" ({n} cached)" if n else ""
|
|
161
|
+
|
|
162
|
+
lines = [
|
|
163
|
+
f"Model: {self.model}",
|
|
164
|
+
f"Files found: {self.n_files}",
|
|
165
|
+
f"Metadata to process: {self.n_metadata_to_process}{_cached(n_meta_cached)}",
|
|
166
|
+
f"References to process: {self.n_references_to_process}{_cached(n_refs_cached)}",
|
|
167
|
+
f"Est. input tokens: ~{self.total_input_tokens:,}",
|
|
168
|
+
f"Est. output tokens: ~{self.estimated_output_tokens:,}",
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
cost = self.cost_usd()
|
|
172
|
+
if cost is not None:
|
|
173
|
+
in_c, out_c = cost
|
|
174
|
+
lines.append(
|
|
175
|
+
f"Est. cost: ~${in_c:.4f} input"
|
|
176
|
+
f" + ~${out_c:.4f} output"
|
|
177
|
+
f" = ~${in_c + out_c:.4f} total"
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
lines.append(
|
|
181
|
+
f"Est. cost: unknown (no pricing data for '{self.model}')"
|
|
182
|
+
)
|
|
183
|
+
lines.append("(Estimates are approximate; actual cost depends on response length.)")
|
|
184
|
+
return "\n".join(lines)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def estimate_extraction_cost(
|
|
188
|
+
layout: OutLayout,
|
|
189
|
+
model: str,
|
|
190
|
+
max_input_chars_metadata: int = DEFAULT_METADATA_INPUT_CHARS,
|
|
191
|
+
) -> ExtractionEstimate:
|
|
192
|
+
"""Estimate LLM token usage for stages 2 and 3 without making any API calls.
|
|
193
|
+
|
|
194
|
+
Reads markdown files from ``layout.markdown_dir`` and skips files whose
|
|
195
|
+
metadata / references caches already exist.
|
|
196
|
+
|
|
197
|
+
Raises :class:`~citegraph.pipeline.StageNotReadyError` when no markdown
|
|
198
|
+
files are found (stage 1 must run first).
|
|
199
|
+
"""
|
|
200
|
+
from citegraph.pipeline import StageNotReadyError, extraction_recipe
|
|
201
|
+
|
|
202
|
+
md_paths = layout.markdown_inputs()
|
|
203
|
+
if not md_paths:
|
|
204
|
+
raise StageNotReadyError(
|
|
205
|
+
f"No markdown files found in {layout.markdown_dir}. "
|
|
206
|
+
"Run `citegraph convert <pdf_dir>` first."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
file_estimates: list[FileEstimate] = []
|
|
210
|
+
metadata_recipe = extraction_recipe(model)
|
|
211
|
+
metadata_recipe['max_input_chars'] = max_input_chars_metadata
|
|
212
|
+
references_recipe = extraction_recipe(model, references=True)
|
|
213
|
+
for md in md_paths:
|
|
214
|
+
metadata_cache = layout.metadata_dir / f"{md.stem}.json"
|
|
215
|
+
references_cache = layout.references_dir / f"{md.stem}.json"
|
|
216
|
+
meta_cached = read_pydantic_cache(metadata_cache, md, PaperMetadata, recipe=metadata_recipe) is not None
|
|
217
|
+
refs_cached = read_pydantic_cache(references_cache, md, Reference, many=True, recipe=references_recipe) is not None
|
|
218
|
+
|
|
219
|
+
if meta_cached and refs_cached:
|
|
220
|
+
file_estimates.append(
|
|
221
|
+
FileEstimate(
|
|
222
|
+
name=md.name,
|
|
223
|
+
metadata_cached=True,
|
|
224
|
+
references_cached=True,
|
|
225
|
+
metadata_input_tokens=0,
|
|
226
|
+
references_input_tokens=0,
|
|
227
|
+
estimated_n_references=0,
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
content = md.read_text(encoding="utf-8")
|
|
233
|
+
|
|
234
|
+
if meta_cached:
|
|
235
|
+
meta_tokens = 0
|
|
236
|
+
else:
|
|
237
|
+
capped = min(len(content), max_input_chars_metadata) if max_input_chars_metadata else len(content)
|
|
238
|
+
meta_tokens = _chars_to_tokens(capped + _METADATA_OVERHEAD_CHARS)
|
|
239
|
+
|
|
240
|
+
if refs_cached:
|
|
241
|
+
refs_tokens = 0
|
|
242
|
+
est_n_refs = 0
|
|
243
|
+
else:
|
|
244
|
+
sliced, _ = slice_to_references_section(content)
|
|
245
|
+
refs_tokens = _chars_to_tokens(len(sliced) + _REFERENCES_OVERHEAD_CHARS)
|
|
246
|
+
est_n_refs = max(1, len(sliced) // _CHARS_PER_REFERENCE_APPROX)
|
|
247
|
+
|
|
248
|
+
file_estimates.append(
|
|
249
|
+
FileEstimate(
|
|
250
|
+
name=md.name,
|
|
251
|
+
metadata_cached=meta_cached,
|
|
252
|
+
references_cached=refs_cached,
|
|
253
|
+
metadata_input_tokens=meta_tokens,
|
|
254
|
+
references_input_tokens=refs_tokens,
|
|
255
|
+
estimated_n_references=est_n_refs,
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return ExtractionEstimate(model=model, files=file_estimates)
|