Jamal Romeh commited on
Commit
2d7922b
·
1 Parent(s): 40eefc0

Patch extractor to use Mistral OCR and fix py3.14 deps

Browse files
Files changed (3) hide show
  1. README.md +12 -0
  2. requirements.txt +2 -2
  3. 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 _word_count(story_text) >= 20:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, _word_count(story_text)):
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
- "Install OCR dependencies (`pypdfium2`, `pytesseract`) and system package "
1854
- "`tesseract-ocr` in the Space build.",
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 (