Skip to content

Commit 823dec4

Browse files
committed
feat(publications): structured OCR for scholarly literature, keeping the footnotes
Adds AI_publication_extraction — journal articles, chapters, monographs and theses through Mistral Document AI with block extraction — and factors the shared Mistral OCR plumbing out into common/mistral_ocr.py, which AI_ocr_extraction/02 now uses too. It is a separate pipeline because AI_ocr_extraction drops every page's header and footer from page 2 onward. On a newspaper that removes the running head; on a journal article it removes the footnotes. Measured on a 33-page article from the Cahiers du CERLESHS (item 4987), the page feet held 53 substantive footnotes against 32 folio numbers — 7,388 characters of apparatus against 56 of noise. So a page foot is kept here unless it is a folio number or repeats across the document. Two supporting changes: * PropertyTarget gains is_public, defaulting to None, meaning "do not decide": an existing value keeps the visibility a curator gave it and a new one is created public, exactly as before. This pipeline sets False, because its sources are copyrighted and a newly created bibo:content would otherwise publish a whole monograph. The flag is also what the Hugging Face export reads as OCR_is_public to decide whether to mask a row's full text, so it reaches well past the archive's own UI. * The PDFs and their JSON sidecars are gitignored: one thesis is 276 MB, and the sidecars carry the full extracted text.
1 parent cda95fd commit 823dec4

13 files changed

Lines changed: 2875 additions & 91 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ AI_ocr_correction/Corrected_TXT/
6666
AI_ocr_correction/TXT/
6767
AI_ocr_extraction/OCR_Results/
6868
AI_ocr_extraction/PDF/
69+
# Scholarly PDFs are large (one thesis is 276 MB) and under copyright; the JSON
70+
# sidecars carry the full extracted text, so they stay local too.
71+
AI_publication_extraction/OCR_Results/
72+
AI_publication_extraction/output/
73+
AI_publication_extraction/PDF/
6974
AI_reference_indexing/output/
7075
AI_summary/backups/
7176
AI_summary/Summaries_EN_TXT/

AI_ocr_extraction/02_mistral_ocr_processor.py

Lines changed: 4 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
"""
3232

3333
import os
34-
import re
3534
import sys
3635
import time
3736
import logging
@@ -53,6 +52,7 @@
5352
from common.rate_limiter import RateLimiter, QuotaExhaustedError, is_mistral_quota_exhausted
5453
from common.retry import retry_with_backoff
5554
from common.console_utils import standard_progress
55+
from common.mistral_ocr import markdown_to_plain_text
5656
from common.log_redaction import install_credential_redaction
5757

5858
try:
@@ -113,95 +113,9 @@ def _is_retryable(error: Exception) -> bool:
113113
return True
114114

115115

116-
# --- Markdown -> plain text normalisation ---------------------------------
117-
# Mistral OCR returns Markdown (headings, emphasis, tables, and image
118-
# placeholders such as ``![img-0.jpeg](img-0.jpeg)``). For an archival
119-
# full-text field consumed by search / NER / embeddings, that formatting is
120-
# noise, so we strip the syntax while preserving the underlying text.
121-
122-
_IMG_RE = re.compile(r"!\[[^\]]*\]\([^)]*\)") # ![alt](url) -> removed
123-
_LINK_RE = re.compile(r"\[([^\]]*)\]\([^)]*\)") # [text](url) -> text
124-
_BOLD_RE = re.compile(r"(\*\*|__)(.+?)\1") # **t** / __t__ -> t
125-
_ITALIC_RE = re.compile(r"(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])") # *t* -> t
126-
_CODE_RE = re.compile(r"`([^`]+)`") # `t` -> t
127-
_HEADER_RE = re.compile(r"^\s{0,3}#{1,6}\s+") # ## Heading -> Heading
128-
_HR_RE = re.compile(r"^\s*([-*_])\1{2,}\s*$") # --- *** ___ -> removed
129-
_BLOCKQUOTE_RE = re.compile(r"^\s{0,3}>\s?") # > quote -> quote
130-
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$")
131-
_MULTI_BLANK_RE = re.compile(r"\n{3,}")
132-
133-
# Mistral OCR sometimes renders footnote superscripts / math as inline LaTeX
134-
# (e.g. ``$^{7}$``, ``XX$^{e}$``, ``$_{2}$``). Convert these to Unicode
135-
# super/subscripts so they match the document's other footnote markers
136-
# (¹ ² ³ …). Only ``$…$`` spans containing \, ^ or _ are treated as math, so
137-
# literal text (and stray dollar signs) is left untouched.
138-
_AUTOLINK_RE = re.compile(r"<(https?://[^>\s]+)>") # <https://x> -> https://x
139-
_INLINE_MATH_RE = re.compile(r"\$([^$\n]*[\\^_][^$\n]*)\$")
140-
_SUP_MAP = str.maketrans("0123456789e+-()n", "⁰¹²³⁴⁵⁶⁷⁸⁹ᵉ⁺⁻⁽⁾ⁿ")
141-
_SUB_MAP = str.maketrans("0123456789+-()", "₀₁₂₃₄₅₆₇₈₉₊₋₍₎")
142-
143-
144-
def _delatex(match) -> str:
145-
"""Render an inline-LaTeX span (``$…$`` content) as readable plain text."""
146-
inner = match.group(1)
147-
inner = re.sub(r"\^\{([^}]*)\}", lambda m: m.group(1).translate(_SUP_MAP), inner)
148-
inner = re.sub(r"_\{([^}]*)\}", lambda m: m.group(1).translate(_SUB_MAP), inner)
149-
inner = re.sub(r"\\[a-zA-Z]+\s*", "", inner) # drop \mathrm, \text, …
150-
return inner.replace("^", "").replace("_", "").replace("{", "").replace("}", "")
151-
152-
153-
def markdown_to_plain_text(md: str) -> str:
154-
"""Convert Mistral OCR Markdown to clean plain text.
155-
156-
Strips headings, emphasis, inline code, links, horizontal rules and image
157-
placeholders; flattens Markdown tables to tab-separated rows. The result
158-
matches the plain-text convention used by the Gemini OCR path.
159-
"""
160-
if not md:
161-
return ""
162-
163-
out_lines: List[str] = []
164-
in_code_fence = False
165-
for raw_line in md.splitlines():
166-
line = raw_line
167-
168-
# Fenced code blocks (```): drop the fences, keep the inner text.
169-
if line.lstrip().startswith("```"):
170-
in_code_fence = not in_code_fence
171-
continue
172-
173-
# Drop horizontal rules and Markdown table separator rows.
174-
if _HR_RE.match(line) or _TABLE_SEP_RE.match(line):
175-
continue
176-
177-
# Strip heading and blockquote markers (keep the text).
178-
line = _HEADER_RE.sub("", line)
179-
line = _BLOCKQUOTE_RE.sub("", line)
180-
181-
# Table content row -> tab-separated cells.
182-
stripped = line.strip()
183-
if stripped.startswith("|") and stripped.endswith("|") and stripped.count("|") >= 2:
184-
cells = [c.strip() for c in stripped.strip("|").split("|")]
185-
line = "\t".join(cells)
186-
187-
out_lines.append(line)
188-
189-
text = "\n".join(out_lines)
190-
191-
# Inline elements (images first, so links don't eat the alt text of ![]()).
192-
text = _IMG_RE.sub("", text)
193-
text = _LINK_RE.sub(r"\1", text)
194-
text = _BOLD_RE.sub(r"\2", text)
195-
text = _ITALIC_RE.sub(r"\1", text)
196-
text = _CODE_RE.sub(r"\1", text)
197-
text = _AUTOLINK_RE.sub(r"\1", text) # unwrap <https://…> autolinks
198-
text = _INLINE_MATH_RE.sub(_delatex, text) # $^{7}$ -> ⁷, XX$^{e}$ -> XXᵉ
199-
200-
# Normalise whitespace: trim trailing spaces, collapse 3+ blank lines.
201-
text = "\n".join(line.rstrip() for line in text.split("\n"))
202-
text = _MULTI_BLANK_RE.sub("\n\n", text)
203-
204-
return text.strip()
116+
# Markdown -> plain text normalisation now lives in ``common/mistral_ocr.py``,
117+
# shared with ``AI_publication_extraction``, which needs the identical rules so
118+
# that ``bibo:content`` reads the same whichever pipeline produced it.
205119

206120

207121
class MistralOCRProcessor:

0 commit comments

Comments
 (0)