Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

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