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,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
|
+
)
|