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/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)