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.
@@ -0,0 +1,81 @@
1
+ """Extract bibliographic metadata of source papers using Gemini."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from citegraph.ids import make_work_id
9
+ from citegraph.llm import GeminiClient, LLMError, parse_structured_response
10
+ from citegraph.schemas import PaperMetadata
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ SYSTEM_INSTRUCTION = (
16
+ "You are a helpful assistant with great expertise in extracting "
17
+ "bibliographic information from academic papers in markdown format."
18
+ )
19
+
20
+ # Title/authors/journal/year live on the first page of essentially every
21
+ # academic paper, so we send only a prefix to keep input tokens small.
22
+ DEFAULT_METADATA_INPUT_CHARS = 12_000
23
+
24
+
25
+ def _build_prompt(paper_content: str) -> str:
26
+ return f"""
27
+ Extract the basic bibliographic information from the following paper. The information should include:
28
+ - Title: The full title of the paper
29
+ - Authors_List: A list of all authors
30
+ - Journal: The journal or venue where the paper was published
31
+ - Year: The publication year as an integer
32
+
33
+ Make sure to not include any dirty characters that might have come from problems in the PDF.
34
+
35
+ Here is the paper content:
36
+ {paper_content}
37
+ """
38
+
39
+
40
+ def extract_metadata_from_markdown(
41
+ markdown_path: Path | str,
42
+ *,
43
+ client: GeminiClient,
44
+ max_input_chars: int = DEFAULT_METADATA_INPUT_CHARS,
45
+ ) -> PaperMetadata:
46
+ """Extract :class:`PaperMetadata` from a single markdown-rendered paper.
47
+
48
+ Only the first ``max_input_chars`` characters of the markdown are sent to
49
+ the LLM. Pass ``0`` to disable truncation.
50
+ """
51
+ markdown_path = Path(markdown_path)
52
+ content = markdown_path.read_text(encoding="utf-8")
53
+ if max_input_chars and len(content) > max_input_chars:
54
+ logger.debug(
55
+ "Truncating %s from %d to %d chars for metadata extraction",
56
+ markdown_path.name,
57
+ len(content),
58
+ max_input_chars,
59
+ )
60
+ content = content[:max_input_chars]
61
+
62
+ response = client.generate_structured(
63
+ prompt=_build_prompt(content),
64
+ response_schema=PaperMetadata,
65
+ system_instruction=SYSTEM_INSTRUCTION,
66
+ max_output_tokens=500,
67
+ )
68
+ parsed = parse_structured_response(response, schema=PaperMetadata)
69
+ if not isinstance(parsed, PaperMetadata):
70
+ raise LLMError(
71
+ f"Expected PaperMetadata for {markdown_path.name}, got {type(parsed)!r}"
72
+ )
73
+ return parsed
74
+
75
+
76
+ def metadata_to_record(meta: PaperMetadata, source_file: str) -> dict:
77
+ """Convert a :class:`PaperMetadata` into a flat dict ready for a DataFrame."""
78
+ record = meta.model_dump()
79
+ record["source_file"] = source_file
80
+ record["id"] = make_work_id(meta.Authors_List or meta.Authors, meta.Year, meta.Title)
81
+ return record
@@ -0,0 +1,376 @@
1
+ """Extract the reference list of a paper using Gemini structured output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from citegraph.llm import GeminiClient, parse_structured_response, response_was_truncated
10
+ from citegraph.schemas import Reference
11
+
12
+ # Long bibliographies sometimes hit ``max_output_tokens``. When that happens we
13
+ # retry once with a higher cap before falling back to the JSON-repair path in
14
+ # :mod:`citegraph.llm`. The hard cap stops us from exceeding model output limits
15
+ # on a runaway retry.
16
+ _TRUNCATION_RETRY_MULTIPLIER = 2
17
+ _TRUNCATION_HARD_CAP_TOKENS = 80_000
18
+
19
+ # A sanity-check ratio: warn if the LLM returned fewer than this fraction of the
20
+ # refs implied by the body's inline-citation count. Set conservatively so we
21
+ # only flag clear shortfalls, not minor undercounts (citation-counting from
22
+ # free text is necessarily approximate).
23
+ _INLINE_CITATION_RATIO_THRESHOLD = 0.5
24
+ # Skip the sanity check on bodies with very few citations — the estimate is
25
+ # noisy at small N and the absolute gap isn't meaningful.
26
+ _INLINE_CITATION_MIN_ESTIMATE = 10
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # Strict regex for the heading of a paper's bibliography section. Matches
32
+ # markdown headers like "## References", "# Bibliography", "### 5. References",
33
+ # "## References and Notes", "## Works Cited". Deliberately rejects headers
34
+ # with substantive trailing text (e.g. "## References to Prior Work") to avoid
35
+ # slicing at the wrong place.
36
+ _REF_KEYWORD = (
37
+ r"(?:references?|bibliography|works\s+cited|literature\s+cited|cited\s+literature)"
38
+ )
39
+ _REF_HEADER_RE = re.compile(
40
+ rf"^\#{{1,6}}\s+(?:[\dIVX]+\.?\s+)?{_REF_KEYWORD}(?:\s+(?:and\s+notes|cited))?\s*$",
41
+ re.IGNORECASE | re.MULTILINE,
42
+ )
43
+
44
+ # Any markdown header, capturing its level — used to find where a
45
+ # references section ends.
46
+ _ANY_HEADER_RE = re.compile(r"^(\#{1,6})\s", re.MULTILINE)
47
+
48
+ # Back matter that follows a bibliography and must not be read as part of it.
49
+ # Deliberately a *stop list* rather than "any header ends the section": the
50
+ # splitter has to fail closed, because headers that interrupt a real reference
51
+ # list are common — running page heads ("## 94 ECONOMIA, Spring 2009",
52
+ # "## A. Pfaff et al.") and repeated article titles both appear mid-list in
53
+ # scanned sources, and truncating there silently drops references. An
54
+ # unrecognised header therefore keeps the historical run-to-end-of-file
55
+ # behaviour. The keyword must *lead* the title (after optional numbering), so
56
+ # "## Appendix A: Instructions" matches while "## References to Prior Work"
57
+ # does not.
58
+ _BACKMATTER_KEYWORD = (
59
+ r"(?:appendi(?:x|ces)"
60
+ r"|supp(?:lementary|orting)\s+(?:online\s+)?(?:material|information|data|methods?)s?"
61
+ r"|acknowledge?ments?"
62
+ r"|author\s+contributions?"
63
+ r"|authors?\s+(?:and|&)\s+a\S*ffiliations?"
64
+ r"|author\s+information"
65
+ r"|about\s+the\s+authors?"
66
+ r"|(?:end|foot)notes?"
67
+ r"|notes?"
68
+ r"|biograph(?:y|ical\s+\S+)"
69
+ r"|curriculum\s+vitae"
70
+ r"|(?:figures?|tables?)\s+(?:and|&)\s+(?:tables?|figures?)"
71
+ r"|index"
72
+ r"|notice\s+of\s+correction"
73
+ r"|(?:previous\s+)?(?:doctoral\s+)?the(?:sis|ses)\s+(?:in|published|publicerade)"
74
+ r"|previous\s+doctoral\s+theses"
75
+ r"|publications?"
76
+ r"|awards?"
77
+ r"|dissemination"
78
+ r"|data\s+availability"
79
+ r"|funding"
80
+ r"|(?:conflicts?|competing)\s+(?:of\s+)?interests?"
81
+ r"|declarations?)"
82
+ )
83
+ _BACKMATTER_HEADER_RE = re.compile(
84
+ rf"^\#{{1,6}}\s+(?:[\dIVXA-Z]+[.):]?\s+)?{_BACKMATTER_KEYWORD}\b",
85
+ re.IGNORECASE,
86
+ )
87
+
88
+
89
+ def _is_backmatter_header(content: str, start: int) -> bool:
90
+ """Whether the markdown header beginning at ``start`` opens back matter."""
91
+ line_end = content.find("\n", start)
92
+ line = content[start:] if line_end == -1 else content[start:line_end]
93
+ return bool(_BACKMATTER_HEADER_RE.match(line))
94
+
95
+
96
+ def split_reference_sections(content: str) -> tuple[str, list[str]]:
97
+ """Return ``(body, sections)`` — all bibliography sections in ``content``.
98
+
99
+ Multi-chapter documents (PhD theses, edited volumes) carry one references
100
+ section per chapter; every :data:`_REF_HEADER_RE` match starts a section.
101
+ A non-final section ends at the next header of the same or higher level
102
+ (sub-headers like ``### Books`` inside a bibliography don't terminate it).
103
+ The final section runs to end-of-file *unless* a same-or-higher-level
104
+ header opens recognised back matter (:data:`_BACKMATTER_HEADER_RE`) —
105
+ appendices, supporting information, a department's list of previous
106
+ doctoral theses — which the extractor would otherwise read as references.
107
+ Anything unrecognised keeps the run-to-end-of-file behaviour, so a running
108
+ page head interrupting a reference list never truncates it. ``body`` is
109
+ everything outside the sections. No match returns ``(content, [])``.
110
+ """
111
+ matches = list(_REF_HEADER_RE.finditer(content))
112
+ if not matches:
113
+ return content, []
114
+
115
+ sections: list[str] = []
116
+ body_parts: list[str] = []
117
+ cursor = 0
118
+ for i, match in enumerate(matches):
119
+ start = match.start()
120
+ if start < cursor:
121
+ continue # nested inside the previous section; already captured
122
+ body_parts.append(content[cursor:start])
123
+ end = len(content)
124
+ level = len(re.match(r"\#+", match.group(0)).group(0))
125
+ is_final = i == len(matches) - 1
126
+ for header in _ANY_HEADER_RE.finditer(content, match.end()):
127
+ if len(header.group(1)) > level:
128
+ continue
129
+ # A non-final section ends at the next section of any kind; the
130
+ # final one only at back matter, so unknown headers fail closed.
131
+ if is_final and not _is_backmatter_header(content, header.start()):
132
+ continue
133
+ end = header.start()
134
+ break
135
+ sections.append(content[start:end])
136
+ cursor = end
137
+ body_parts.append(content[cursor:])
138
+ return "".join(body_parts), sections
139
+
140
+
141
+ def split_at_references_header(content: str) -> tuple[str, str | None]:
142
+ """Return ``(body, refs)`` with all bibliography sections in ``refs``.
143
+
144
+ Joins every section found by :func:`split_reference_sections` (a single
145
+ section comes back byte-identical to the raw slice). If none is found,
146
+ returns ``(content, None)`` so callers can decide how to react to the
147
+ missing header.
148
+ """
149
+ body, sections = split_reference_sections(content)
150
+ if not sections:
151
+ return content, None
152
+ return body, "\n\n".join(sections)
153
+
154
+
155
+ def slice_to_references_section(content: str) -> tuple[str, bool]:
156
+ """Return ``(text, found)`` — content from the bibliography header onward.
157
+
158
+ If no header matches, returns the original ``content`` unchanged with
159
+ ``found=False`` so callers can fall back to sending the full markdown
160
+ rather than dropping the paper.
161
+ """
162
+ body, refs = split_at_references_header(content)
163
+ if refs is None:
164
+ return content, False
165
+ return refs, True
166
+
167
+
168
+ # Numeric in-text citations: \[12\], \[12, 15\], \[12-15\], \[12, 15, 18\], etc.
169
+ _INLINE_NUMERIC_RE = re.compile(r"\[([\d,\s\-–]+)\]")
170
+ _NUMERIC_SPLIT_RE = re.compile(r"[,\s\-–]+")
171
+
172
+ # Author-year citations in either parenthetical or narrative form:
173
+ # "(Smith, 2019)", "(Smith and Jones, 2019)", "(Smith et al., 2019)"
174
+ # "Smith (2019)", "Smith and Jones (2019)", "Smith et al. (2019)"
175
+ # The author group is captured loosely; we lower-case it for deduplication.
176
+ _AUTHOR = r"[A-Z][A-Za-z\-']+(?:\s+(?:and|&)\s+[A-Z][A-Za-z\-']+)?(?:\s+et\s+al\.?)?"
177
+ _INLINE_PAREN_RE = re.compile(rf"\(({_AUTHOR}),?\s+(\d{{4}})[a-z]?\)")
178
+ _INLINE_NARRATIVE_RE = re.compile(rf"\b({_AUTHOR})\s+\((\d{{4}})[a-z]?\)")
179
+
180
+
181
+ def estimate_inline_citations(body: str) -> int:
182
+ """Approximate the number of distinct references cited inline in ``body``.
183
+
184
+ Counts:
185
+
186
+ - Unique integers inside ``[...]`` brackets (IEEE/ACM-style numeric).
187
+ - Unique ``(author_lowercased, year)`` tokens from author-year citations,
188
+ both ``(Smith, 2019)`` and narrative ``Smith (2019)`` forms.
189
+
190
+ Returns the *sum* of the two — papers typically use one style, so summing
191
+ gives a sensible total without much double-counting. The result is a
192
+ deliberately rough estimate intended for order-of-magnitude sanity checks
193
+ (e.g. "we extracted 12 refs but the body cites 80 — likely a parsing
194
+ failure"), not a precise citation count.
195
+ """
196
+ numeric: set[int] = set()
197
+ for match in _INLINE_NUMERIC_RE.finditer(body):
198
+ for token in _NUMERIC_SPLIT_RE.split(match.group(1)):
199
+ if token.isdigit():
200
+ numeric.add(int(token))
201
+
202
+ author_year: set[tuple[str, str]] = set()
203
+ for pattern in (_INLINE_PAREN_RE, _INLINE_NARRATIVE_RE):
204
+ for match in pattern.finditer(body):
205
+ author = re.sub(r"\s+", " ", match.group(1).lower().strip())
206
+ author_year.add((author, match.group(2)))
207
+
208
+ return len(numeric) + len(author_year)
209
+
210
+
211
+ SYSTEM_INSTRUCTION = (
212
+ "You are an expert assistant specializing in extracting bibliographic "
213
+ "information from the references or bibliography section of an academic "
214
+ "paper. You can interpret APA, MLA, Chicago and IEEE citation formats, "
215
+ "and you handle complex layouts including double-column pages. Your "
216
+ "goal is to extract every reference comprehensively and return them in "
217
+ "a structured form."
218
+ )
219
+
220
+
221
+ def _build_prompt(paper_content: str) -> str:
222
+ return f"""
223
+ # Instructions
224
+ Please extract the complete bibliographic information for all the papers cited in the references or bibliography section of the document below. For each reference, return:
225
+
226
+ - Title: The full title of the paper
227
+ - Authors_List: A list of all authors
228
+ - Journal: The journal or venue where the paper was published
229
+ - Year: The publication year as an integer
230
+
231
+ Take into account the following:
232
+
233
+ 1. Capture every reference. Do not skip any.
234
+ 2. Focus on the references / bibliography section at the end of the paper.
235
+ 3. Strip any garbled characters that may have come from PDF extraction artefacts.
236
+ 4. If a field is genuinely missing in the original, use an empty string for text fields and 0 for Year.
237
+
238
+ # Paper
239
+ {paper_content}
240
+ """
241
+
242
+
243
+ def extract_references_from_markdown(
244
+ markdown_path: Path | str,
245
+ *,
246
+ client: GeminiClient,
247
+ max_output_tokens: int | None = None,
248
+ slice_to_references: bool = True,
249
+ ) -> list[Reference]:
250
+ """Extract a list of :class:`Reference` from a single markdown-rendered paper.
251
+
252
+ By default the markdown is sliced to its bibliography section before being
253
+ sent to the LLM (see :func:`slice_to_references_section`). Pass
254
+ ``slice_to_references=False`` to send the full file.
255
+
256
+ If the LLM response is cut off by ``max_output_tokens`` (detected via
257
+ :func:`response_was_truncated`), the call is retried once with the cap
258
+ raised by ``_TRUNCATION_RETRY_MULTIPLIER``, up to
259
+ ``_TRUNCATION_HARD_CAP_TOKENS``. A persistent truncation is logged at
260
+ error level and the partial result is still returned.
261
+ """
262
+ markdown_path = Path(markdown_path)
263
+ content = markdown_path.read_text(encoding="utf-8")
264
+
265
+ body_for_sanity_check: str | None = None
266
+ sections = [content]
267
+ if slice_to_references:
268
+ body, found_sections = split_reference_sections(content)
269
+ if found_sections:
270
+ logger.debug(
271
+ "Sliced %s to %d references section(s): %d -> %d chars",
272
+ markdown_path.name,
273
+ len(found_sections),
274
+ len(content),
275
+ sum(len(s) for s in found_sections),
276
+ )
277
+ if len(found_sections) > 1:
278
+ logger.info(
279
+ "Found %d references sections in %s (multi-chapter document); "
280
+ "extracting each separately",
281
+ len(found_sections),
282
+ markdown_path.name,
283
+ )
284
+ sections = found_sections
285
+ body_for_sanity_check = body
286
+ else:
287
+ logger.info(
288
+ "No references-section header found in %s; sending full markdown",
289
+ markdown_path.name,
290
+ )
291
+
292
+ initial_cap = max_output_tokens or client.default_max_output_tokens
293
+ parsed: list[Reference] = []
294
+ for idx, section in enumerate(sections):
295
+ label = markdown_path.name
296
+ if len(sections) > 1:
297
+ label = f"{markdown_path.name} (section {idx + 1}/{len(sections)})"
298
+ parsed.extend(
299
+ _extract_section(client=client, section=section, label=label, initial_cap=initial_cap)
300
+ )
301
+
302
+ if body_for_sanity_check is not None:
303
+ _check_extracted_count(markdown_path, body_for_sanity_check, len(parsed))
304
+
305
+ return parsed
306
+
307
+
308
+ def _extract_section(
309
+ *, client: GeminiClient, section: str, label: str, initial_cap: int
310
+ ) -> list[Reference]:
311
+ """Run the extraction call for one references section, with truncation retry."""
312
+ prompt = _build_prompt(section)
313
+
314
+ def _call(cap: int):
315
+ return client.generate_structured(
316
+ prompt=prompt,
317
+ response_schema=list[Reference],
318
+ system_instruction=SYSTEM_INSTRUCTION,
319
+ max_output_tokens=cap,
320
+ )
321
+
322
+ response = _call(initial_cap)
323
+
324
+ if response_was_truncated(response):
325
+ retry_cap = min(
326
+ initial_cap * _TRUNCATION_RETRY_MULTIPLIER, _TRUNCATION_HARD_CAP_TOKENS
327
+ )
328
+ if retry_cap > initial_cap:
329
+ logger.warning(
330
+ "References response for %s was truncated at %d tokens; "
331
+ "retrying with cap %d",
332
+ label,
333
+ initial_cap,
334
+ retry_cap,
335
+ )
336
+ response = _call(retry_cap)
337
+ if response_was_truncated(response):
338
+ logger.error(
339
+ "References response for %s still truncated at %d tokens; "
340
+ "extracted list will be incomplete",
341
+ label,
342
+ retry_cap,
343
+ )
344
+ else:
345
+ logger.error(
346
+ "References response for %s truncated at hard cap %d tokens; "
347
+ "cannot retry — extracted list will be incomplete",
348
+ label,
349
+ initial_cap,
350
+ )
351
+
352
+ parsed = parse_structured_response(response, schema=Reference)
353
+ if not isinstance(parsed, list):
354
+ logger.warning("Expected a list of references for %s, got %r", label, type(parsed))
355
+ return []
356
+ return parsed
357
+
358
+
359
+ def _check_extracted_count(
360
+ markdown_path: Path, body: str, extracted_count: int
361
+ ) -> None:
362
+ """Warn if the extracted count is far below the body's inline-citation estimate."""
363
+ estimate = estimate_inline_citations(body)
364
+ if estimate < _INLINE_CITATION_MIN_ESTIMATE:
365
+ return
366
+ if extracted_count >= estimate * _INLINE_CITATION_RATIO_THRESHOLD:
367
+ return
368
+ logger.warning(
369
+ "Possible references undercount for %s: extracted %d but body cites ~%d "
370
+ "distinct works (threshold %.0f%%). The bibliography may have been "
371
+ "partially parsed or the LLM may have skipped entries.",
372
+ markdown_path.name,
373
+ extracted_count,
374
+ estimate,
375
+ _INLINE_CITATION_RATIO_THRESHOLD * 100,
376
+ )