Spaces:
Sleeping
Sleeping
Jamal Romeh commited on
Commit ·
2d7922b
1
Parent(s): 40eefc0
Patch extractor to use Mistral OCR and fix py3.14 deps
Browse files- README.md +12 -0
- requirements.txt +2 -2
- src/codex_extractor.py +181 -7
README.md
CHANGED
|
@@ -16,3 +16,15 @@ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-
|
|
| 16 |
|
| 17 |
- Diagnostic audit trail: `smoke_signal/manifest/DIAGNOSTIC_AUDIT_TRAIL.md`
|
| 18 |
- Bug report template: `smoke_signal/manifest/BUG_REPORT_TEMPLATE.md`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
- Diagnostic audit trail: `smoke_signal/manifest/DIAGNOSTIC_AUDIT_TRAIL.md`
|
| 18 |
- Bug report template: `smoke_signal/manifest/BUG_REPORT_TEMPLATE.md`
|
| 19 |
+
|
| 20 |
+
## OCR Runtime (Mistral OCR)
|
| 21 |
+
|
| 22 |
+
- PDF OCR is wired to Mistral OCR using model `mistral-ocr-latest`.
|
| 23 |
+
- Add the Hugging Face Space secret:
|
| 24 |
+
- `MISTRAL_API_KEY`
|
| 25 |
+
- The extractor sends uploaded PDFs to Mistral OCR as base64 `document_url` payloads and preserves page-order output for fingerprint metrics.
|
| 26 |
+
|
| 27 |
+
## Fingerprint Data Boundary
|
| 28 |
+
|
| 29 |
+
- Final author fingerprint records store derived numeric/categorical metrics only.
|
| 30 |
+
- Source manuscript text is not stored in the final fingerprint library.
|
requirements.txt
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
gradio>=4.44,<7
|
| 2 |
huggingface_hub<1.0
|
|
|
|
| 3 |
nltk>=3.8
|
| 4 |
openpyxl>=3.1
|
| 5 |
openai>=1.30.0
|
|
@@ -8,7 +9,6 @@ pdfplumber>=0.10
|
|
| 8 |
Pillow>=10.0.0
|
| 9 |
plotly>=5.22
|
| 10 |
pymupdf>=1.23
|
| 11 |
-
pymupdf>=1.23.0
|
| 12 |
pypdfium2>=4.30
|
| 13 |
pytesseract>=0.3.10
|
| 14 |
-
surya-ocr>=0.4.0
|
|
|
|
| 1 |
gradio>=4.44,<7
|
| 2 |
huggingface_hub<1.0
|
| 3 |
+
mistralai>=2.4.5
|
| 4 |
nltk>=3.8
|
| 5 |
openpyxl>=3.1
|
| 6 |
openai>=1.30.0
|
|
|
|
| 9 |
Pillow>=10.0.0
|
| 10 |
plotly>=5.22
|
| 11 |
pymupdf>=1.23
|
|
|
|
| 12 |
pypdfium2>=4.30
|
| 13 |
pytesseract>=0.3.10
|
| 14 |
+
surya-ocr>=0.4.0; python_version < "3.13"
|
src/codex_extractor.py
CHANGED
|
@@ -88,6 +88,7 @@ import os
|
|
| 88 |
import re
|
| 89 |
import string
|
| 90 |
import zipfile
|
|
|
|
| 91 |
from collections import Counter
|
| 92 |
from pathlib import Path
|
| 93 |
from shutil import which
|
|
@@ -113,6 +114,18 @@ try:
|
|
| 113 |
except Exception:
|
| 114 |
PYTESSERACT_AVAILABLE = False
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
try:
|
| 117 |
import nltk
|
| 118 |
import socket as _socket
|
|
@@ -299,6 +312,137 @@ def _word_count(text: str) -> int:
|
|
| 299 |
return len(SIMPLE_TOKENISE_PATTERN.findall((text or "").lower()))
|
| 300 |
|
| 301 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
def _ocr_runtime_ready() -> bool:
|
| 303 |
return PDFIUM_AVAILABLE and PYTESSERACT_AVAILABLE and which("tesseract") is not None
|
| 304 |
|
|
@@ -447,20 +591,50 @@ def extract_text_from_pdf(
|
|
| 447 |
|
| 448 |
raw_text = "\n".join(raw_parts)
|
| 449 |
story_text = "\n".join(story_parts)
|
|
|
|
|
|
|
|
|
|
| 450 |
|
| 451 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
return story_text, raw_text, page_trace
|
| 453 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
try:
|
| 455 |
ocr_story_text, ocr_raw_text, ocr_trace = extract_text_from_pdf_ocr(
|
| 456 |
pdf_path, start_page=start_page, end_page=end_page,
|
| 457 |
)
|
| 458 |
-
if _word_count(ocr_story_text) >= max(20,
|
| 459 |
-
# Mark all original pages as ocr_fallback and merge OCR trace
|
| 460 |
for entry in page_trace:
|
| 461 |
if entry["classification"] == "story":
|
| 462 |
entry["classification"] = "ocr_fallback"
|
| 463 |
-
entry["skip_reason"] = "native text layer empty; OCR used"
|
| 464 |
return ocr_story_text, ocr_raw_text, ocr_trace
|
| 465 |
except Exception:
|
| 466 |
pass
|
|
@@ -1847,11 +2021,11 @@ def process_upload(
|
|
| 1847 |
)
|
| 1848 |
|
| 1849 |
if not story_text or len(story_text.split()) < 20:
|
| 1850 |
-
if str(file_path).lower().endswith(".pdf") and not _ocr_runtime_ready():
|
| 1851 |
return (
|
| 1852 |
"ERROR: No usable text extracted from file and OCR runtime is unavailable. "
|
| 1853 |
-
"
|
| 1854 |
-
"`tesseract-ocr`
|
| 1855 |
{},
|
| 1856 |
)
|
| 1857 |
return (
|
|
|
|
| 88 |
import re
|
| 89 |
import string
|
| 90 |
import zipfile
|
| 91 |
+
from base64 import b64encode
|
| 92 |
from collections import Counter
|
| 93 |
from pathlib import Path
|
| 94 |
from shutil import which
|
|
|
|
| 114 |
except Exception:
|
| 115 |
PYTESSERACT_AVAILABLE = False
|
| 116 |
|
| 117 |
+
try:
|
| 118 |
+
from mistralai import Mistral
|
| 119 |
+
MISTRAL_AVAILABLE = True
|
| 120 |
+
except Exception:
|
| 121 |
+
try:
|
| 122 |
+
# Back-compat import path used by older SDK docs.
|
| 123 |
+
from mistralai.client import Mistral # type: ignore
|
| 124 |
+
MISTRAL_AVAILABLE = True
|
| 125 |
+
except Exception:
|
| 126 |
+
Mistral = None # type: ignore
|
| 127 |
+
MISTRAL_AVAILABLE = False
|
| 128 |
+
|
| 129 |
try:
|
| 130 |
import nltk
|
| 131 |
import socket as _socket
|
|
|
|
| 312 |
return len(SIMPLE_TOKENISE_PATTERN.findall((text or "").lower()))
|
| 313 |
|
| 314 |
|
| 315 |
+
def _mistral_ocr_ready() -> bool:
|
| 316 |
+
return MISTRAL_AVAILABLE and bool(os.getenv("MISTRAL_API_KEY"))
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def _mistral_client() -> Any:
|
| 320 |
+
api_key = os.getenv("MISTRAL_API_KEY", "").strip()
|
| 321 |
+
if not api_key:
|
| 322 |
+
raise RuntimeError(
|
| 323 |
+
"MISTRAL_API_KEY is missing. Add it as a Hugging Face Space secret."
|
| 324 |
+
)
|
| 325 |
+
if not MISTRAL_AVAILABLE:
|
| 326 |
+
raise RuntimeError(
|
| 327 |
+
"mistralai SDK is not installed. Add `mistralai` to requirements.txt."
|
| 328 |
+
)
|
| 329 |
+
return Mistral(api_key=api_key)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _mistral_page_markdown(page: Any) -> str:
|
| 333 |
+
if isinstance(page, dict):
|
| 334 |
+
return str(page.get("markdown") or "")
|
| 335 |
+
return str(getattr(page, "markdown", "") or "")
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def _mistral_page_index(page: Any, default_idx: int) -> int:
|
| 339 |
+
raw_idx: Any
|
| 340 |
+
if isinstance(page, dict):
|
| 341 |
+
raw_idx = page.get("index", default_idx)
|
| 342 |
+
else:
|
| 343 |
+
raw_idx = getattr(page, "index", default_idx)
|
| 344 |
+
try:
|
| 345 |
+
return int(raw_idx)
|
| 346 |
+
except Exception:
|
| 347 |
+
return default_idx
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def extract_text_from_pdf_mistral(
|
| 351 |
+
pdf_path: str | Path,
|
| 352 |
+
start_page: int | None = None,
|
| 353 |
+
end_page: int | None = None,
|
| 354 |
+
) -> tuple[str, str, list[dict]]:
|
| 355 |
+
"""
|
| 356 |
+
OCR extraction using Mistral OCR (`mistral-ocr-latest`).
|
| 357 |
+
|
| 358 |
+
Returns (story_text, raw_text, page_trace).
|
| 359 |
+
page_trace follows the same shape used by the rest of the pipeline.
|
| 360 |
+
"""
|
| 361 |
+
client = _mistral_client()
|
| 362 |
+
max_pages = int(os.getenv("OCR_MAX_PAGES", "300"))
|
| 363 |
+
include_image_base64 = os.getenv("MISTRAL_OCR_INCLUDE_IMAGE_BASE64", "0").strip().lower() in {
|
| 364 |
+
"1", "true", "yes",
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
pdf_bytes = Path(pdf_path).read_bytes()
|
| 368 |
+
encoded_pdf = b64encode(pdf_bytes).decode("utf-8")
|
| 369 |
+
data_url = f"data:application/pdf;base64,{encoded_pdf}"
|
| 370 |
+
|
| 371 |
+
ocr_kwargs: dict[str, Any] = {
|
| 372 |
+
"model": "mistral-ocr-latest",
|
| 373 |
+
"document": {
|
| 374 |
+
"type": "document_url",
|
| 375 |
+
"document_url": data_url,
|
| 376 |
+
},
|
| 377 |
+
"include_image_base64": include_image_base64,
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
# If user selected page range, send it directly to OCR (0-based indices).
|
| 381 |
+
if start_page is not None or end_page is not None:
|
| 382 |
+
idx_start = max(0, (start_page - 1) if start_page is not None else 0)
|
| 383 |
+
idx_end = (end_page - 1) if end_page is not None else idx_start
|
| 384 |
+
idx_end = max(idx_start, idx_end)
|
| 385 |
+
ocr_kwargs["pages"] = list(range(idx_start, idx_end + 1))
|
| 386 |
+
|
| 387 |
+
ocr_response = client.ocr.process(**ocr_kwargs)
|
| 388 |
+
pages = getattr(ocr_response, "pages", None)
|
| 389 |
+
if pages is None and isinstance(ocr_response, dict):
|
| 390 |
+
pages = ocr_response.get("pages")
|
| 391 |
+
if not isinstance(pages, list):
|
| 392 |
+
raise RuntimeError("Mistral OCR response did not include a page list.")
|
| 393 |
+
|
| 394 |
+
if max_pages > 0:
|
| 395 |
+
pages = pages[:max_pages]
|
| 396 |
+
|
| 397 |
+
raw_parts: list[str] = []
|
| 398 |
+
story_parts: list[str] = []
|
| 399 |
+
page_trace: list[dict] = []
|
| 400 |
+
|
| 401 |
+
for local_idx, page in enumerate(pages):
|
| 402 |
+
page_idx = _mistral_page_index(page, local_idx)
|
| 403 |
+
page_text = _mistral_page_markdown(page)
|
| 404 |
+
raw_parts.append(page_text)
|
| 405 |
+
raw_wc = _word_count(page_text)
|
| 406 |
+
|
| 407 |
+
classification = "story"
|
| 408 |
+
skip_reason = ""
|
| 409 |
+
included = False
|
| 410 |
+
|
| 411 |
+
if start_page is not None or end_page is not None:
|
| 412 |
+
idx_start = max(0, (start_page - 1) if start_page is not None else 0)
|
| 413 |
+
idx_end = (end_page - 1) if end_page is not None else idx_start
|
| 414 |
+
idx_end = max(idx_start, idx_end)
|
| 415 |
+
if idx_start <= page_idx <= idx_end:
|
| 416 |
+
story_parts.append(page_text)
|
| 417 |
+
included = True
|
| 418 |
+
else:
|
| 419 |
+
classification = "out_of_range"
|
| 420 |
+
skip_reason = f"outside user range {start_page}–{end_page}"
|
| 421 |
+
else:
|
| 422 |
+
# Keep existing cover/front-matter behavior to preserve metric quality.
|
| 423 |
+
if len(pages) > 1 and local_idx == 0:
|
| 424 |
+
classification = "cover"
|
| 425 |
+
skip_reason = "first page auto-skipped as cover"
|
| 426 |
+
elif is_front_matter_page(page_text):
|
| 427 |
+
classification = "front_matter"
|
| 428 |
+
skip_reason = "front-matter signals detected"
|
| 429 |
+
else:
|
| 430 |
+
story_parts.append(page_text)
|
| 431 |
+
included = True
|
| 432 |
+
|
| 433 |
+
cleaned_wc = _word_count(page_text) if included else 0
|
| 434 |
+
page_trace.append({
|
| 435 |
+
"page_number": page_idx + 1,
|
| 436 |
+
"raw_word_count": raw_wc,
|
| 437 |
+
"cleaned_word_count": cleaned_wc,
|
| 438 |
+
"classification": classification,
|
| 439 |
+
"included": included,
|
| 440 |
+
"skip_reason": skip_reason,
|
| 441 |
+
})
|
| 442 |
+
|
| 443 |
+
return "\n".join(story_parts), "\n".join(raw_parts), page_trace
|
| 444 |
+
|
| 445 |
+
|
| 446 |
def _ocr_runtime_ready() -> bool:
|
| 447 |
return PDFIUM_AVAILABLE and PYTESSERACT_AVAILABLE and which("tesseract") is not None
|
| 448 |
|
|
|
|
| 591 |
|
| 592 |
raw_text = "\n".join(raw_parts)
|
| 593 |
story_text = "\n".join(story_parts)
|
| 594 |
+
story_wc = _word_count(story_text)
|
| 595 |
+
mistral_first = os.getenv("OCR_USE_MISTRAL", "1").strip().lower() not in {"0", "false", "no"}
|
| 596 |
+
mistral_min_words = int(os.getenv("MISTRAL_OCR_MIN_WORDS", "20"))
|
| 597 |
|
| 598 |
+
if mistral_first:
|
| 599 |
+
try:
|
| 600 |
+
mistral_story_text, mistral_raw_text, mistral_trace = extract_text_from_pdf_mistral(
|
| 601 |
+
pdf_path,
|
| 602 |
+
start_page=start_page,
|
| 603 |
+
end_page=end_page,
|
| 604 |
+
)
|
| 605 |
+
mistral_wc = _word_count(mistral_story_text)
|
| 606 |
+
if mistral_wc >= max(mistral_min_words, story_wc):
|
| 607 |
+
return mistral_story_text, mistral_raw_text, mistral_trace
|
| 608 |
+
except Exception:
|
| 609 |
+
# Keep extraction resilient if API key is absent or OCR request fails.
|
| 610 |
+
pass
|
| 611 |
+
|
| 612 |
+
if story_wc >= 20:
|
| 613 |
return story_text, raw_text, page_trace
|
| 614 |
|
| 615 |
+
# If not already attempted as first-pass, try Mistral OCR before legacy OCR fallback.
|
| 616 |
+
if not mistral_first:
|
| 617 |
+
try:
|
| 618 |
+
mistral_story_text, mistral_raw_text, mistral_trace = extract_text_from_pdf_mistral(
|
| 619 |
+
pdf_path,
|
| 620 |
+
start_page=start_page,
|
| 621 |
+
end_page=end_page,
|
| 622 |
+
)
|
| 623 |
+
if _word_count(mistral_story_text) >= max(mistral_min_words, story_wc):
|
| 624 |
+
return mistral_story_text, mistral_raw_text, mistral_trace
|
| 625 |
+
except Exception:
|
| 626 |
+
pass
|
| 627 |
+
|
| 628 |
+
# Legacy local OCR fallback (kept for resilience if Mistral is unavailable).
|
| 629 |
try:
|
| 630 |
ocr_story_text, ocr_raw_text, ocr_trace = extract_text_from_pdf_ocr(
|
| 631 |
pdf_path, start_page=start_page, end_page=end_page,
|
| 632 |
)
|
| 633 |
+
if _word_count(ocr_story_text) >= max(20, story_wc):
|
|
|
|
| 634 |
for entry in page_trace:
|
| 635 |
if entry["classification"] == "story":
|
| 636 |
entry["classification"] = "ocr_fallback"
|
| 637 |
+
entry["skip_reason"] = "native text layer empty; legacy OCR used"
|
| 638 |
return ocr_story_text, ocr_raw_text, ocr_trace
|
| 639 |
except Exception:
|
| 640 |
pass
|
|
|
|
| 2021 |
)
|
| 2022 |
|
| 2023 |
if not story_text or len(story_text.split()) < 20:
|
| 2024 |
+
if str(file_path).lower().endswith(".pdf") and not _mistral_ocr_ready() and not _ocr_runtime_ready():
|
| 2025 |
return (
|
| 2026 |
"ERROR: No usable text extracted from file and OCR runtime is unavailable. "
|
| 2027 |
+
"Set Hugging Face secret `MISTRAL_API_KEY` for Mistral OCR (`mistral-ocr-latest`) "
|
| 2028 |
+
"or install legacy OCR dependencies (`pypdfium2`, `pytesseract`, `tesseract-ocr`).",
|
| 2029 |
{},
|
| 2030 |
)
|
| 2031 |
return (
|