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/jel.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""JEL classification codes of the core papers.
|
|
2
|
+
|
|
3
|
+
Most economics papers print their JEL codes on the title page, and docling
|
|
4
|
+
carries that line into the markdown, so the codes are read back with a
|
|
5
|
+
regex rather than another LLM call: adding a field to ``PaperMetadata``
|
|
6
|
+
would invalidate the metadata cache, and re-extracting every paper could
|
|
7
|
+
shift titles, hence work ids, hence every id-keyed curated file. Papers
|
|
8
|
+
whose PDF carries no codes (most working papers and chapters) get them by hand through
|
|
9
|
+
the ``JEL_Codes`` column of ``work_annotations.csv``; a hand-assigned value
|
|
10
|
+
overrides extraction, because a reviewer who opened the PDF beats a regex.
|
|
11
|
+
|
|
12
|
+
``core_jel_codes`` is the merged view with provenance, and ``jel_codes.csv``
|
|
13
|
+
is it on disk. The general-category names come from the AEA's
|
|
14
|
+
``classifications.xml`` (the ``X00`` entries, minus their ``: General`` tail).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
from citegraph.annotations import load_annotations
|
|
25
|
+
from citegraph.io import OutLayout
|
|
26
|
+
|
|
27
|
+
JEL_GENERAL_CATEGORIES: dict[str, str] = {
|
|
28
|
+
"A": "General Economics and Teaching",
|
|
29
|
+
"B": "History of Economic Thought, Methodology, and Heterodox Approaches",
|
|
30
|
+
"C": "Mathematical and Quantitative Methods",
|
|
31
|
+
"D": "Microeconomics",
|
|
32
|
+
"E": "Macroeconomics and Monetary Economics",
|
|
33
|
+
"F": "International Economics",
|
|
34
|
+
"G": "Financial Economics",
|
|
35
|
+
"H": "Public Economics",
|
|
36
|
+
"I": "Health, Education, and Welfare",
|
|
37
|
+
"J": "Labor and Demographic Economics",
|
|
38
|
+
"K": "Law and Economics",
|
|
39
|
+
"L": "Industrial Organization",
|
|
40
|
+
"M": "Business Administration and Business Economics; Marketing; Accounting; "
|
|
41
|
+
"Personnel Economics",
|
|
42
|
+
"N": "Economic History",
|
|
43
|
+
"O": "Economic Development, Innovation, Technological Change, and Growth",
|
|
44
|
+
"P": "Political Economy and Comparative Economic Systems",
|
|
45
|
+
"Q": "Agricultural and Natural Resource Economics; Environmental and Ecological Economics",
|
|
46
|
+
"R": "Urban, Rural, Regional, Real Estate, and Transportation Economics",
|
|
47
|
+
"Y": "Miscellaneous Categories",
|
|
48
|
+
"Z": "Other Special Topics",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# docling leaves font escapes in the text; these are the ones that land in JEL lines.
|
|
52
|
+
_ARTEFACTS = {"/uni00A0": " ", "/C1": " ", "/uniFB01": "fi", "/fication": "fication"}
|
|
53
|
+
|
|
54
|
+
# "JEL", an optional label ("classification", "codes", "classification numbers",
|
|
55
|
+
# with the fi-ligature mangled every way docling manages), then the rest of the line.
|
|
56
|
+
_LABEL_RE = re.compile(
|
|
57
|
+
r"\bJEL\b[ \t]*[-–:]?[ \t]*"
|
|
58
|
+
r"(?:classi\s*fi?\s*c?ations?|codes?|numbers?)?"
|
|
59
|
+
r"(?:[ \t]+(?:numbers?|codes?))?"
|
|
60
|
+
r"[ \t]*[:.]?[ \t]*(?P<rest>[^\n]*)",
|
|
61
|
+
re.IGNORECASE,
|
|
62
|
+
)
|
|
63
|
+
_CODE_RE = re.compile(r"\b([A-Z0]) ?(\d{1,2})\b")
|
|
64
|
+
_FILLER_RE = re.compile(r"\band\b|[\s,;.·/()\[\]\-–]")
|
|
65
|
+
_CODE_FORMAT_RE = re.compile(r"^[A-Z]\d{1,2}$")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _codes_in(chunk: str) -> list[str]:
|
|
69
|
+
"""Codes in ``chunk`` when it is a code list and nothing else."""
|
|
70
|
+
codes = [
|
|
71
|
+
("O" if letter == "0" else letter) + digits for letter, digits in _CODE_RE.findall(chunk)
|
|
72
|
+
]
|
|
73
|
+
codes = [c for c in codes if c[0] in JEL_GENERAL_CATEGORIES]
|
|
74
|
+
if not codes or _FILLER_RE.sub("", _CODE_RE.sub("", chunk)).strip():
|
|
75
|
+
return []
|
|
76
|
+
return codes
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def extract_jel_codes(markdown: str) -> list[str]:
|
|
80
|
+
"""JEL codes printed in ``markdown``, in order, deduplicated.
|
|
81
|
+
|
|
82
|
+
Every ``JEL`` block is read — a thesis prints one per chapter — and a
|
|
83
|
+
block's codes are either on the label's own line or in the paragraphs
|
|
84
|
+
right after it, read until the first paragraph that is not a code list
|
|
85
|
+
(an Elsevier "Article info" box prints one code per paragraph). A line
|
|
86
|
+
that mentions JEL and then says anything else is prose, not a code list,
|
|
87
|
+
and contributes nothing.
|
|
88
|
+
"""
|
|
89
|
+
text = markdown
|
|
90
|
+
for artefact, replacement in _ARTEFACTS.items():
|
|
91
|
+
text = text.replace(artefact, replacement)
|
|
92
|
+
found: dict[str, None] = {}
|
|
93
|
+
for match in _LABEL_RE.finditer(text):
|
|
94
|
+
rest = match.group("rest").strip()
|
|
95
|
+
if rest:
|
|
96
|
+
found.update(dict.fromkeys(_codes_in(rest)))
|
|
97
|
+
continue
|
|
98
|
+
for paragraph in re.split(r"\n\s*\n", text[match.end() :].lstrip("\n")):
|
|
99
|
+
codes = _codes_in(paragraph)
|
|
100
|
+
if not codes:
|
|
101
|
+
break
|
|
102
|
+
found.update(dict.fromkeys(codes))
|
|
103
|
+
return list(found)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_manual(value: object, work_id: str) -> list[str]:
|
|
107
|
+
if value is None or pd.isna(value) or not str(value).strip():
|
|
108
|
+
return []
|
|
109
|
+
tokens = [token for token in re.split(r"[;,\s]+", str(value).strip()) if token]
|
|
110
|
+
for token in tokens:
|
|
111
|
+
code = token.upper()
|
|
112
|
+
if not _CODE_FORMAT_RE.match(code) or code[0] not in JEL_GENERAL_CATEGORIES:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"work_annotations.csv: work {work_id!r} has JEL_Codes entry {token!r}; "
|
|
115
|
+
"expected a letter and one or two digits, e.g. D71 or Q2"
|
|
116
|
+
)
|
|
117
|
+
return list(dict.fromkeys(token.upper() for token in tokens))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def core_jel_codes(out_dir: str | Path) -> pd.DataFrame:
|
|
121
|
+
"""One row per core work: ``JEL_Codes``, ``JEL_General`` and where they came from.
|
|
122
|
+
|
|
123
|
+
``JEL_Source`` is ``manual`` (from ``work_annotations.csv``), ``extracted``
|
|
124
|
+
(from the markdown) or ``missing``. ``JEL_General`` is the sorted set of
|
|
125
|
+
first letters — the level the composition figure plots.
|
|
126
|
+
"""
|
|
127
|
+
layout = OutLayout(out_dir)
|
|
128
|
+
works = pd.read_csv(layout.works_csv, index_col="id", keep_default_na=False)
|
|
129
|
+
core = works[works["ring"] == 0]
|
|
130
|
+
annotations = load_annotations(
|
|
131
|
+
layout.work_annotations_csv, works=works, schema_path=layout.annotation_schema_csv
|
|
132
|
+
)
|
|
133
|
+
manual = (
|
|
134
|
+
annotations["JEL_Codes"] if "JEL_Codes" in annotations.columns else pd.Series(dtype=object)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
rows = []
|
|
138
|
+
for work_id, work in core.iterrows():
|
|
139
|
+
codes = _parse_manual(manual.get(work_id), str(work_id))
|
|
140
|
+
source = "manual"
|
|
141
|
+
if not codes:
|
|
142
|
+
markdown = layout.markdown_dir / str(work["source_file"])
|
|
143
|
+
if work["source_file"] and markdown.exists():
|
|
144
|
+
codes = extract_jel_codes(markdown.read_text(encoding="utf-8", errors="replace"))
|
|
145
|
+
source = "extracted" if codes else "missing"
|
|
146
|
+
rows.append(
|
|
147
|
+
{
|
|
148
|
+
"id": work_id,
|
|
149
|
+
"source_file": work["source_file"],
|
|
150
|
+
"Title": work["Title"],
|
|
151
|
+
"JEL_Codes": codes,
|
|
152
|
+
"JEL_General": sorted({code[0] for code in codes}),
|
|
153
|
+
"JEL_Source": source,
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
columns = ["id", "source_file", "Title", "JEL_Codes", "JEL_General", "JEL_Source"]
|
|
157
|
+
return pd.DataFrame(rows, columns=columns).set_index("id")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def write_jel_codes(out_dir: str | Path) -> Path:
|
|
161
|
+
"""Write ``jel_codes.csv`` (lists joined by ``;``) and return its path."""
|
|
162
|
+
frame = core_jel_codes(out_dir)
|
|
163
|
+
for column in ("JEL_Codes", "JEL_General"):
|
|
164
|
+
frame[column] = frame[column].map(";".join)
|
|
165
|
+
path = OutLayout(out_dir).jel_codes_csv
|
|
166
|
+
frame.to_csv(path)
|
|
167
|
+
return path
|
citegraph/journals.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Canonical journal names.
|
|
2
|
+
|
|
3
|
+
The same journal reaches us under many spellings — the AER under six in one
|
|
4
|
+
corpus, *Ecological Economics* under three — so any rollup that groups on the
|
|
5
|
+
raw string understates concentration. Enrichment already collapses most of the
|
|
6
|
+
variation by preferring the provider's container title; this module folds what
|
|
7
|
+
is left.
|
|
8
|
+
|
|
9
|
+
Two deliberate limits:
|
|
10
|
+
|
|
11
|
+
- Folding is conservative. It normalises case, diacritics, punctuation,
|
|
12
|
+
``&``/``and`` and a leading ``The``, and nothing else. Abbreviations are
|
|
13
|
+
*not* matched automatically: an initials heuristic over one corpus paired
|
|
14
|
+
"public choice" with "protecting the commons" and "energy policy" with
|
|
15
|
+
"economics and philosophy". Abbreviations belong in the alias file, where a
|
|
16
|
+
human vouches for them; there are only about ten worth writing.
|
|
17
|
+
- Preprint repositories are not journals. OpenAlex reports RePEc, SSRN and
|
|
18
|
+
publisher eBook platforms as container titles, which would rank RePEc third
|
|
19
|
+
and SSRN ninth among this corpus's "journals". They fold to
|
|
20
|
+
:data:`WORKING_PAPER` instead, and an alias can override that.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import csv
|
|
26
|
+
import math
|
|
27
|
+
import re
|
|
28
|
+
import unicodedata
|
|
29
|
+
from collections import Counter
|
|
30
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import TYPE_CHECKING
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
import pandas as pd
|
|
36
|
+
|
|
37
|
+
WORKING_PAPER = "Working paper"
|
|
38
|
+
|
|
39
|
+
# Matched against the folded key, so punctuation and case are already gone.
|
|
40
|
+
_REPOSITORY_PATTERNS = (
|
|
41
|
+
"repec",
|
|
42
|
+
"research papers in economics",
|
|
43
|
+
"ssrn",
|
|
44
|
+
"ebooks",
|
|
45
|
+
"social science research network",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_LEADING_ARTICLE = re.compile(r"^the\s+")
|
|
49
|
+
_AMPERSAND = re.compile(r"\s*&\s*")
|
|
50
|
+
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def journal_key(name: object) -> str:
|
|
54
|
+
"""Fold one journal name to its grouping key.
|
|
55
|
+
|
|
56
|
+
Returns ``""`` for anything missing, so a work with no journal never
|
|
57
|
+
joins a group.
|
|
58
|
+
"""
|
|
59
|
+
if name is None or (isinstance(name, float) and math.isnan(name)):
|
|
60
|
+
return ""
|
|
61
|
+
text = str(name).strip()
|
|
62
|
+
if not text:
|
|
63
|
+
return ""
|
|
64
|
+
text = unicodedata.normalize("NFKD", text)
|
|
65
|
+
text = "".join(c for c in text if not unicodedata.combining(c))
|
|
66
|
+
text = _AMPERSAND.sub(" and ", text.lower())
|
|
67
|
+
text = _LEADING_ARTICLE.sub("", text)
|
|
68
|
+
return _NON_ALNUM.sub(" ", text).strip()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_repository(key: str) -> bool:
|
|
72
|
+
return any(pattern in key for pattern in _REPOSITORY_PATTERNS)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def canonicalize_journals(
|
|
76
|
+
names: Iterable[object],
|
|
77
|
+
aliases: Mapping[str, str] | None = None,
|
|
78
|
+
*,
|
|
79
|
+
strict: bool = False,
|
|
80
|
+
) -> dict[str, str]:
|
|
81
|
+
"""Map every observed journal spelling to one canonical display name.
|
|
82
|
+
|
|
83
|
+
The display name is the most frequent spelling in ``names``, with the
|
|
84
|
+
longest winning a tie — the same "fullest, most frequent wins" rule the
|
|
85
|
+
author stage uses, so a rare lowercased variant never names a group.
|
|
86
|
+
|
|
87
|
+
``aliases`` is applied last and matched by folded key, so the user writes
|
|
88
|
+
one spelling ("Am. Econ. Rev.") and every variant of it follows. With
|
|
89
|
+
``strict``, an alias that matches nothing raises: a curated row that
|
|
90
|
+
silently does not apply is worse than no row at all.
|
|
91
|
+
"""
|
|
92
|
+
observed = [str(n).strip() for n in names if journal_key(n)]
|
|
93
|
+
counts: dict[str, Counter] = {}
|
|
94
|
+
for raw in observed:
|
|
95
|
+
counts.setdefault(journal_key(raw), Counter())[raw] += 1
|
|
96
|
+
|
|
97
|
+
alias_by_key = {journal_key(raw): canonical for raw, canonical in (aliases or {}).items()}
|
|
98
|
+
if strict:
|
|
99
|
+
unused = unused_aliases(aliases, observed)
|
|
100
|
+
if unused:
|
|
101
|
+
raise ValueError(unused_alias_message(unused))
|
|
102
|
+
|
|
103
|
+
display: dict[str, str] = {}
|
|
104
|
+
for key, spellings in counts.items():
|
|
105
|
+
if key in alias_by_key:
|
|
106
|
+
display[key] = alias_by_key[key]
|
|
107
|
+
elif _is_repository(key):
|
|
108
|
+
display[key] = WORKING_PAPER
|
|
109
|
+
else:
|
|
110
|
+
display[key] = max(spellings.items(), key=lambda kv: (kv[1], len(kv[0])))[0]
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
(str(n).strip() if journal_key(n) else ""): display.get(journal_key(n), "")
|
|
114
|
+
for n in names
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def unused_aliases(
|
|
119
|
+
aliases: Mapping[str, str] | None, *vocabularies: Iterable[object]
|
|
120
|
+
) -> list[str]:
|
|
121
|
+
"""Curated rows whose folded key appears in none of ``vocabularies``.
|
|
122
|
+
|
|
123
|
+
A corpus spells its journals twice — once as the bibliography extracted
|
|
124
|
+
them, once as the provider reports them — and a row is only dead if it
|
|
125
|
+
matches *neither*. Ask this against every vocabulary the corpus has rather
|
|
126
|
+
than against one fold, or a row is rejected for the very thing it does:
|
|
127
|
+
an alias expanding "Ecol. Econ." matches nothing among provider titles
|
|
128
|
+
precisely because the provider already says "Ecological Economics".
|
|
129
|
+
"""
|
|
130
|
+
known: set[str] = set()
|
|
131
|
+
for names in vocabularies:
|
|
132
|
+
known.update(key for key in (journal_key(n) for n in names) if key)
|
|
133
|
+
return sorted(raw for raw in (aliases or {}) if journal_key(raw) not in known)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def unused_alias_message(unused: Sequence[str]) -> str:
|
|
137
|
+
"""The one wording for a dead curated row, shared by every caller."""
|
|
138
|
+
return (
|
|
139
|
+
"Journal aliases match no journal in this corpus: "
|
|
140
|
+
+ ", ".join(repr(u) for u in unused)
|
|
141
|
+
+ ". Fix the spelling or delete the row."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_journal_aliases(path: Path | str | None) -> dict[str, str]:
|
|
146
|
+
"""Read ``journal_aliases.csv`` (``raw,canonical``); absent file is empty."""
|
|
147
|
+
if path is None:
|
|
148
|
+
return {}
|
|
149
|
+
path = Path(path)
|
|
150
|
+
if not path.exists():
|
|
151
|
+
return {}
|
|
152
|
+
with path.open(encoding="utf-8", newline="") as handle:
|
|
153
|
+
rows = list(csv.DictReader(handle))
|
|
154
|
+
aliases: dict[str, str] = {}
|
|
155
|
+
for row in rows:
|
|
156
|
+
raw = (row.get("raw") or "").strip()
|
|
157
|
+
canonical = (row.get("canonical") or "").strip()
|
|
158
|
+
if raw and canonical:
|
|
159
|
+
aliases[raw] = canonical
|
|
160
|
+
return aliases
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def add_canonical_journals(
|
|
164
|
+
df: pd.DataFrame,
|
|
165
|
+
aliases: Mapping[str, str] | None = None,
|
|
166
|
+
*,
|
|
167
|
+
strict: bool = False,
|
|
168
|
+
) -> pd.DataFrame:
|
|
169
|
+
"""Return ``df`` with a ``Journal_Canonical`` column beside ``Journal``.
|
|
170
|
+
|
|
171
|
+
The observed name is never overwritten — the canonical form is what you
|
|
172
|
+
group on, the observed one is what the record actually said.
|
|
173
|
+
"""
|
|
174
|
+
out = df.copy()
|
|
175
|
+
if "Journal" not in out.columns:
|
|
176
|
+
out["Journal_Canonical"] = ""
|
|
177
|
+
return out
|
|
178
|
+
mapping = canonicalize_journals(out["Journal"].tolist(), aliases, strict=strict)
|
|
179
|
+
out["Journal_Canonical"] = [
|
|
180
|
+
mapping.get(str(n).strip() if journal_key(n) else "", "") for n in out["Journal"]
|
|
181
|
+
]
|
|
182
|
+
return out
|
citegraph/llm.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Thin Gemini wrapper used by the extraction modules.
|
|
2
|
+
|
|
3
|
+
We keep all of the provider-specific code in this single file so that the
|
|
4
|
+
rest of the package only deals with Pydantic models and plain Python
|
|
5
|
+
objects. The wrapper handles:
|
|
6
|
+
|
|
7
|
+
- Lazy client construction with a configurable API key/model.
|
|
8
|
+
- Retry/backoff via :mod:`tenacity` for transient errors.
|
|
9
|
+
- A :func:`fix_incomplete_json_string` JSON repair fallback for the rare
|
|
10
|
+
cases where Gemini truncates a response.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
from tenacity import (
|
|
21
|
+
retry,
|
|
22
|
+
retry_if_exception_type,
|
|
23
|
+
stop_after_attempt,
|
|
24
|
+
wait_exponential,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from citegraph.config import Settings, get_settings
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fix_incomplete_json_string(input_string: str) -> str | None:
|
|
33
|
+
"""Attempt to recover a truncated JSON-array string.
|
|
34
|
+
|
|
35
|
+
The current Gemini structured-output path almost always returns valid
|
|
36
|
+
JSON, but on long bibliographies the response can hit the output-token
|
|
37
|
+
limit and arrive truncated. This function:
|
|
38
|
+
|
|
39
|
+
1. Strips surrounding single quotes (a quirk we sometimes see).
|
|
40
|
+
2. Cuts the string at the last complete ``}``.
|
|
41
|
+
3. Closes the trailing ``]`` so the result parses as a JSON array.
|
|
42
|
+
|
|
43
|
+
Returns ``None`` if no complete object boundary is found.
|
|
44
|
+
"""
|
|
45
|
+
cleaned = input_string
|
|
46
|
+
if cleaned.startswith("'"):
|
|
47
|
+
cleaned = cleaned[1:]
|
|
48
|
+
if cleaned.endswith("'"):
|
|
49
|
+
cleaned = cleaned[:-1]
|
|
50
|
+
|
|
51
|
+
last_obj_end = cleaned.rfind("}")
|
|
52
|
+
if last_obj_end == -1:
|
|
53
|
+
return None
|
|
54
|
+
return cleaned[: last_obj_end + 1] + "\n]"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LLMError(RuntimeError):
|
|
58
|
+
"""Raised when the LLM call fails after all retries."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def response_was_truncated(response: Any) -> bool:
|
|
62
|
+
"""Return True if the Gemini response was cut off by ``max_output_tokens``.
|
|
63
|
+
|
|
64
|
+
Inspects ``response.candidates[*].finish_reason`` and matches the value
|
|
65
|
+
"MAX_TOKENS" (the SDK may expose it as an enum or a plain string). Returns
|
|
66
|
+
``False`` defensively when the response shape is unexpected — callers can
|
|
67
|
+
fall back to other signals (e.g. JSON parse failure) in that case.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
candidates = getattr(response, "candidates", None) or []
|
|
71
|
+
for cand in candidates:
|
|
72
|
+
reason = getattr(cand, "finish_reason", None)
|
|
73
|
+
name = getattr(reason, "name", None) or str(reason or "")
|
|
74
|
+
if "MAX_TOKENS" in name:
|
|
75
|
+
return True
|
|
76
|
+
except Exception: # noqa: BLE001
|
|
77
|
+
return False
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class GeminiClient:
|
|
82
|
+
"""Minimal Gemini client used by the extraction modules."""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
api_key: str | None = None,
|
|
88
|
+
model: str | None = None,
|
|
89
|
+
settings: Settings | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
self._settings = settings or get_settings()
|
|
92
|
+
self._api_key = api_key or self._settings.require_api_key()
|
|
93
|
+
self.model = model or self._settings.citegraph_model
|
|
94
|
+
self._client: Any | None = None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def client(self) -> Any:
|
|
98
|
+
if self._client is None:
|
|
99
|
+
from google import genai
|
|
100
|
+
|
|
101
|
+
self._client = genai.Client(api_key=self._api_key)
|
|
102
|
+
return self._client
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def default_max_output_tokens(self) -> int:
|
|
106
|
+
"""Output-token cap used when ``generate_structured`` gets ``None``."""
|
|
107
|
+
return self._settings.citegraph_max_output_tokens
|
|
108
|
+
|
|
109
|
+
@retry(
|
|
110
|
+
retry=retry_if_exception_type(Exception),
|
|
111
|
+
stop=stop_after_attempt(3),
|
|
112
|
+
wait=wait_exponential(multiplier=2, min=2, max=30),
|
|
113
|
+
reraise=True,
|
|
114
|
+
)
|
|
115
|
+
def _generate(self, *, contents: str, config: dict[str, Any]) -> Any:
|
|
116
|
+
return self.client.models.generate_content(
|
|
117
|
+
model=self.model,
|
|
118
|
+
contents=contents,
|
|
119
|
+
config=config,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def generate_structured(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
prompt: str,
|
|
126
|
+
response_schema: Any,
|
|
127
|
+
system_instruction: str | None = None,
|
|
128
|
+
max_output_tokens: int | None = None,
|
|
129
|
+
) -> Any:
|
|
130
|
+
"""Run a structured-output Gemini call and return the response object.
|
|
131
|
+
|
|
132
|
+
``response_schema`` is passed through to Gemini unchanged. Use a
|
|
133
|
+
Pydantic ``BaseModel`` subclass for a single-object response or a
|
|
134
|
+
list type (e.g. ``list[Reference]``) for an array response.
|
|
135
|
+
"""
|
|
136
|
+
config: dict[str, Any] = {
|
|
137
|
+
"response_mime_type": "application/json",
|
|
138
|
+
"response_schema": response_schema,
|
|
139
|
+
"max_output_tokens": (
|
|
140
|
+
max_output_tokens or self._settings.citegraph_max_output_tokens
|
|
141
|
+
),
|
|
142
|
+
}
|
|
143
|
+
if system_instruction:
|
|
144
|
+
config["system_instruction"] = system_instruction
|
|
145
|
+
try:
|
|
146
|
+
return self._generate(contents=prompt, config=config)
|
|
147
|
+
except Exception as exc: # noqa: BLE001
|
|
148
|
+
raise LLMError(f"Gemini call failed: {exc}") from exc
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_structured_response(
|
|
152
|
+
response: Any,
|
|
153
|
+
schema: type[BaseModel] | None = None,
|
|
154
|
+
) -> Any:
|
|
155
|
+
"""Parse a Gemini response, falling back to JSON repair if needed.
|
|
156
|
+
|
|
157
|
+
- If ``response.parsed`` is populated, return it.
|
|
158
|
+
- Otherwise try ``json.loads(response.text)``.
|
|
159
|
+
- On failure, attempt :func:`fix_incomplete_json_string` and retry.
|
|
160
|
+
|
|
161
|
+
When ``schema`` is provided, list elements / single objects are
|
|
162
|
+
validated through that Pydantic model before being returned.
|
|
163
|
+
"""
|
|
164
|
+
parsed = getattr(response, "parsed", None)
|
|
165
|
+
if parsed is not None:
|
|
166
|
+
data = parsed
|
|
167
|
+
else:
|
|
168
|
+
raw = getattr(response, "text", None)
|
|
169
|
+
if not raw:
|
|
170
|
+
raise LLMError("Empty Gemini response (no .parsed and no .text).")
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
data = json.loads(raw)
|
|
174
|
+
except json.JSONDecodeError as exc:
|
|
175
|
+
logger.warning("Gemini returned invalid JSON, attempting repair: %s", exc)
|
|
176
|
+
repaired = fix_incomplete_json_string(raw)
|
|
177
|
+
if repaired is None:
|
|
178
|
+
raise LLMError("Could not repair truncated JSON response.") from exc
|
|
179
|
+
data = json.loads(repaired)
|
|
180
|
+
|
|
181
|
+
if schema is None:
|
|
182
|
+
return data
|
|
183
|
+
|
|
184
|
+
if isinstance(data, list):
|
|
185
|
+
return [schema.model_validate(item) for item in data]
|
|
186
|
+
return schema.model_validate(data)
|