e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
260 lines
11 KiB
Python
260 lines
11 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Scanned-PDF OCR fallback: a PDF page with no text layer is rendered and transcribed
|
|
by the vision model during ingestion, so image-only PDFs become searchable. The vision
|
|
call is stubbed, so no model is needed."""
|
|
|
|
import pymupdf
|
|
|
|
from core.rag import captioner, ingestion, parsers, store, tool
|
|
|
|
|
|
def _image_only_pdf(path, *, pages = 1):
|
|
"""A PDF whose pages carry only a raster image, so get_text returns ''."""
|
|
doc = pymupdf.open()
|
|
pix = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 120, 120))
|
|
pix.clear_with(220)
|
|
for _ in range(pages):
|
|
page = doc.new_page()
|
|
page.insert_image(page.rect, pixmap = pix)
|
|
doc.save(str(path))
|
|
doc.close()
|
|
|
|
|
|
def _text_pdf(path, body):
|
|
doc = pymupdf.open()
|
|
page = doc.new_page()
|
|
page.insert_textbox(pymupdf.Rect(40, 40, 550, 800), body, fontsize = 11)
|
|
doc.save(str(path))
|
|
doc.close()
|
|
|
|
|
|
def _ingest(rag_conn, thread_id, filename, path):
|
|
"""Drive the real ingestion worker synchronously and return the document row."""
|
|
scope = store.thread_scope(thread_id)
|
|
document_id = store.create_document(
|
|
rag_conn,
|
|
scope = scope,
|
|
filename = filename,
|
|
sha256 = filename,
|
|
thread_id = thread_id,
|
|
status = "pending",
|
|
stored_path = str(path),
|
|
)
|
|
job_id = ingestion._new_job(rag_conn, document_id, scope)
|
|
ingestion._run(job_id, document_id, scope, str(path), None)
|
|
return store.get_document(rag_conn, document_id)
|
|
|
|
|
|
# ── parsers.render_pdf_pages ─────────────────────────────────────────
|
|
|
|
|
|
def test_render_pdf_pages_returns_png_per_page(tmp_path):
|
|
pdf = tmp_path / "two.pdf"
|
|
_image_only_pdf(pdf, pages = 2)
|
|
out = parsers.render_pdf_pages(str(pdf), [1, 2], dpi = 72)
|
|
assert set(out) == {1, 2}
|
|
assert all(b.startswith(b"\x89PNG") for b in out.values())
|
|
|
|
|
|
def test_render_pdf_pages_excludes_unwanted(tmp_path):
|
|
pdf = tmp_path / "three.pdf"
|
|
_image_only_pdf(pdf, pages = 3)
|
|
out = parsers.render_pdf_pages(str(pdf), [2], dpi = 72)
|
|
assert set(out) == {2}
|
|
|
|
|
|
def test_render_pdf_pages_empty_request(tmp_path):
|
|
pdf = tmp_path / "one.pdf"
|
|
_image_only_pdf(pdf, pages = 1)
|
|
assert parsers.render_pdf_pages(str(pdf), [], dpi = 72) == {}
|
|
|
|
|
|
# ── captioner.ocr_pages gating ───────────────────────────────────────
|
|
|
|
|
|
def test_ocr_pages_no_endpoint(monkeypatch):
|
|
monkeypatch.setattr(captioner, "vision_endpoint", lambda: None)
|
|
assert captioner.ocr_pages({1: b"x"}) == {}
|
|
|
|
|
|
def test_collapse_runaway_caps_repeated_lines():
|
|
# A looping model repeats a line hundreds of times; the guard caps it, keeps repeats.
|
|
text = "\n".join(["TITLE"] * 200 + ["body"] + ["Add & Norm"] * 3)
|
|
out = captioner._collapse_runaway(text)
|
|
lines = out.splitlines()
|
|
assert lines.count("TITLE") == 3 # 200 -> 3
|
|
assert lines.count("Add & Norm") == 3 # legitimate triple survives
|
|
assert "body" in lines
|
|
|
|
|
|
def test_collapse_runaway_caps_interleaved_repeats():
|
|
# Models also loop non-consecutively; the global per-line cap bounds those too.
|
|
text = "\n".join(["Llion Vaswani Google", "Niki Parmar Google"] * 40)
|
|
out = captioner._collapse_runaway(text)
|
|
lines = [ln for ln in out.splitlines() if ln.strip()]
|
|
assert lines.count("Llion Vaswani Google") <= 8
|
|
assert lines.count("Niki Parmar Google") <= 8
|
|
|
|
|
|
def test_collapse_runaway_noop_on_normal_text():
|
|
text = "Heading\n\nFirst paragraph.\nSecond paragraph.\n\nFooter"
|
|
assert captioner._collapse_runaway(text) == text
|
|
|
|
|
|
def test_ocr_pages_applies_runaway_guard(monkeypatch):
|
|
monkeypatch.setattr(captioner.config, "OCR_SCANNED", True)
|
|
monkeypatch.setattr(captioner, "_ocr_one", lambda *a: "\n".join(["X"] * 50))
|
|
out = captioner.ocr_pages({1: b"img"}, endpoint = ("http://x", "local"))
|
|
assert out[1].splitlines().count("X") == 3 # guard applied to stored text
|
|
|
|
|
|
def test_ocr_pages_transcribes_and_caps(monkeypatch):
|
|
monkeypatch.setattr(captioner.config, "OCR_SCANNED", True)
|
|
monkeypatch.setattr(captioner.config, "OCR_MAX_PAGES", 1)
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
captioner,
|
|
"_ocr_one",
|
|
lambda base, model, b, t: (calls.append(1) or "transcribed text"),
|
|
)
|
|
out = captioner.ocr_pages({1: b"a", 2: b"b"}, endpoint = ("http://x", "local"))
|
|
assert out == {1: "transcribed text"} # page 2 dropped by the cap
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_ocr_scanned_pages_merges_short_text_layer(rag_conn, monkeypatch):
|
|
# Near-empty pages can still have meaningful extractable text; OCR augments it
|
|
# rather than replacing it with a fallible vision transcription.
|
|
scope = store.thread_scope("t1")
|
|
document_id = store.create_document(rag_conn, scope = scope, filename = "scan.pdf", sha256 = "h")
|
|
job_id = ingestion._new_job(rag_conn, document_id, scope)
|
|
pages = [parsers.Page("ID-42", 1, 5)]
|
|
|
|
monkeypatch.setattr(captioner.config, "OCR_SCANNED", True)
|
|
monkeypatch.setattr(captioner.config, "OCR_MIN_CHARS", 16)
|
|
monkeypatch.setattr(captioner, "vision_endpoint", lambda: ("http://x", "local"))
|
|
monkeypatch.setattr(parsers, "render_pdf_pages", lambda *a, **k: {1: b"png"})
|
|
monkeypatch.setattr(captioner, "ocr_pages", lambda page_pngs: {1: "OCR body text"})
|
|
|
|
out, ocred = ingestion._ocr_scanned_pages(pages, "scan.pdf", rag_conn, job_id)
|
|
assert ocred == {1}
|
|
assert out[0].text == "ID-42\n\nOCR body text"
|
|
|
|
|
|
# ── end-to-end ingestion ─────────────────────────────────────────────
|
|
|
|
|
|
def test_scanned_pdf_is_ocred_into_chunks(rag_conn, stub_embeddings, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(captioner.config, "OCR_SCANNED", True)
|
|
monkeypatch.setattr(captioner, "vision_endpoint", lambda: ("http://x", "local"))
|
|
monkeypatch.setattr(
|
|
captioner, "_ocr_one", lambda base, model, b, t: "Invoice total is zebra-42 due Friday"
|
|
)
|
|
|
|
pdf = tmp_path / "scan.pdf"
|
|
_image_only_pdf(pdf, pages = 1)
|
|
doc = _ingest(rag_conn, "t1", "scan.pdf", pdf)
|
|
|
|
assert doc["status"] == "completed"
|
|
assert doc["num_chunks"] >= 1
|
|
# The OCR'd text is now indexed and reaches whole-document injection.
|
|
text, _sources = tool.whole_document_context(scope_thread_id = "t1", max_tokens = 6000)
|
|
assert "zebra-42" in text
|
|
|
|
|
|
def test_scanned_page_past_ocr_cap_is_still_captioned(
|
|
rag_conn, stub_embeddings, monkeypatch, tmp_path
|
|
):
|
|
# OCR is capped to one page, so page 2 is scanned but never transcribed. Figure
|
|
# captioning must still cover it (we exclude only the pages OCR actually handled),
|
|
# so a chart on an un-OCR'd scanned page is not silently dropped.
|
|
monkeypatch.setattr(captioner.config, "OCR_SCANNED", True)
|
|
monkeypatch.setattr(captioner.config, "OCR_MAX_PAGES", 1)
|
|
monkeypatch.setattr(captioner.config, "CAPTION_IMAGES", True)
|
|
monkeypatch.setattr(captioner, "vision_endpoint", lambda: ("http://x", "local"))
|
|
monkeypatch.setattr(captioner, "_ocr_one", lambda *a: "scanned page alpha")
|
|
monkeypatch.setattr(captioner, "_caption_one", lambda *a: "figure caption bravo")
|
|
|
|
pdf = tmp_path / "scan2.pdf"
|
|
_image_only_pdf(pdf, pages = 2)
|
|
doc = _ingest(rag_conn, "t1", "scan2.pdf", pdf)
|
|
|
|
assert doc["status"] == "completed"
|
|
text, _ = tool.whole_document_context(scope_thread_id = "t1", max_tokens = 6000)
|
|
assert "scanned page alpha" in text # page 1 OCR'd, within the cap
|
|
assert "figure caption bravo" in text # page 2 past the cap -> captioned, not dropped
|
|
|
|
|
|
def test_born_digital_pdf_skips_ocr(rag_conn, stub_embeddings, monkeypatch, tmp_path):
|
|
called = []
|
|
monkeypatch.setattr(captioner.config, "OCR_SCANNED", True)
|
|
monkeypatch.setattr(captioner, "_ocr_one", lambda *a: called.append(1) or "should not run")
|
|
|
|
pdf = tmp_path / "digital.pdf"
|
|
_text_pdf(pdf, "Real born digital body text. " * 30 + "marker-quokka")
|
|
doc = _ingest(rag_conn, "t1", "digital.pdf", pdf)
|
|
|
|
assert doc["status"] == "completed"
|
|
assert called == [] # page had real text -> never considered scanned
|
|
text, _sources = tool.whole_document_context(scope_thread_id = "t1", max_tokens = 6000)
|
|
assert "marker-quokka" in text
|
|
|
|
|
|
def _ingest_with_ocr(rag_conn, thread_id, path, ocr):
|
|
scope = store.thread_scope(thread_id)
|
|
document_id = store.create_document(
|
|
rag_conn,
|
|
scope = scope,
|
|
filename = "scan.pdf",
|
|
sha256 = str(path) + str(ocr),
|
|
thread_id = thread_id,
|
|
status = "pending",
|
|
stored_path = str(path),
|
|
)
|
|
job_id = ingestion._new_job(rag_conn, document_id, scope)
|
|
ingestion._run(job_id, document_id, scope, str(path), None, ocr = ocr)
|
|
return store.get_document(rag_conn, document_id)
|
|
|
|
|
|
def test_ocr_override_false_skips_ocr_when_config_on(
|
|
rag_conn, stub_embeddings, monkeypatch, tmp_path
|
|
):
|
|
# Config default ON, but the per-upload toggle (ocr=False) skips OCR.
|
|
monkeypatch.setattr(captioner.config, "OCR_SCANNED", True)
|
|
monkeypatch.setattr(captioner, "vision_endpoint", lambda: ("http://x", "local"))
|
|
monkeypatch.setattr(captioner, "_ocr_one", lambda *a: "should not run")
|
|
pdf = tmp_path / "scan.pdf"
|
|
_image_only_pdf(pdf, pages = 1)
|
|
doc = _ingest_with_ocr(rag_conn, "t1", pdf, ocr = False)
|
|
assert doc["num_chunks"] == 0 # scanned page left empty
|
|
|
|
|
|
def test_ocr_override_true_runs_ocr_when_config_off(
|
|
rag_conn, stub_embeddings, monkeypatch, tmp_path
|
|
):
|
|
# Config default OFF, but the per-upload toggle (ocr=True) forces OCR on.
|
|
monkeypatch.setattr(captioner.config, "OCR_SCANNED", False)
|
|
monkeypatch.setattr(captioner, "vision_endpoint", lambda: ("http://x", "local"))
|
|
monkeypatch.setattr(captioner, "_ocr_one", lambda *a: "forced ocr text quokka")
|
|
pdf = tmp_path / "scan.pdf"
|
|
_image_only_pdf(pdf, pages = 1)
|
|
doc = _ingest_with_ocr(rag_conn, "t1", pdf, ocr = True)
|
|
assert doc["num_chunks"] >= 1
|
|
text, _ = tool.whole_document_context(scope_thread_id = "t1", max_tokens = 6000)
|
|
assert "quokka" in text
|
|
|
|
|
|
def test_ocr_disabled_leaves_scanned_pdf_empty(rag_conn, stub_embeddings, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(captioner.config, "OCR_SCANNED", False)
|
|
|
|
pdf = tmp_path / "scan.pdf"
|
|
_image_only_pdf(pdf, pages = 1)
|
|
doc = _ingest(rag_conn, "t1", "scan.pdf", pdf)
|
|
|
|
# With OCR off, a text-less scanned page yields no chunks (prior behavior).
|
|
assert doc["status"] == "completed"
|
|
assert doc["num_chunks"] == 0
|
|
assert tool.whole_document_context(scope_thread_id = "t1", max_tokens = 6000) is None
|