Files
e5deed8d62 fix(knowledge): warn when container recreate drops local runtimes (#2130)
Packages installed from the dashboard live in the image venv and disappear
when the container is recreated, while settings and indexes stay on the data
volume. Say so at startup, and bake the same extras into the manual FnOS
source image.

Co-authored-by: jubaoliang <jubaoliang@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 23:14:12 +08:00

265 lines
8.3 KiB
Python

"""Tests for optional knowledge-base OCR configuration and routing."""
from __future__ import annotations
import logging
from pathlib import Path
from types import SimpleNamespace
import pytest
from pypdf import PdfWriter
from octop.infra.knowledge import ocr
from octop.infra.knowledge.parse import parse_document
def test_warns_when_enabled_local_ocr_deps_are_missing(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
monkeypatch.setattr(ocr, "local_ocr_deps_available", lambda: False)
monkeypatch.setattr(ocr, "pdf_ocr_deps_available", lambda: False)
values = {
"knowledge_ocr_enabled": "true",
"knowledge_ocr_backend": "onnx",
}
with caplog.at_level(logging.WARNING):
ocr.warn_if_local_ocr_deps_missing(values.get)
assert "knowledge-ocr" in caplog.text
def test_skips_ocr_dep_warning_when_runtime_is_present_or_unused(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
monkeypatch.setattr(ocr, "local_ocr_deps_available", lambda: True)
enabled = {"knowledge_ocr_enabled": "true", "knowledge_ocr_backend": "onnx"}
disabled = {"knowledge_ocr_enabled": "false", "knowledge_ocr_backend": "onnx"}
remote = {
"knowledge_ocr_enabled": "true",
"knowledge_ocr_backend": "remote",
"knowledge_ocr_model": "vision-1",
"knowledge_ocr_provider_id": "7",
}
with caplog.at_level(logging.WARNING):
ocr.warn_if_local_ocr_deps_missing(enabled.get)
ocr.warn_if_local_ocr_deps_missing(disabled.get)
monkeypatch.setattr(ocr, "local_ocr_deps_available", lambda: False)
ocr.warn_if_local_ocr_deps_missing(remote.get)
assert caplog.records == []
def test_ocr_disabled_by_default() -> None:
capability = ocr.get_ocr_capability(lambda _key: None)
assert capability["enabled"] is False
assert capability["backend"] == "onnx"
assert capability["usable"] is False
def test_set_local_ocr_settings(monkeypatch: pytest.MonkeyPatch) -> None:
values: dict[str, str] = {}
monkeypatch.setattr(ocr, "local_ocr_deps_available", lambda: True)
ocr.set_ocr_settings(
values.__setitem__,
enabled=True,
backend="onnx",
model=None,
provider_id=None,
)
assert values == {
"knowledge_ocr_enabled": "true",
"knowledge_ocr_backend": "onnx",
"knowledge_ocr_model": "rapidocr",
"knowledge_ocr_provider_id": "",
}
def test_remote_ocr_requires_image_capable_model() -> None:
provider = SimpleNamespace(
enabled=True,
api_key="secret",
base_url="https://example.test/v1",
name="Remote",
get_models=lambda: [
{"id": "text-only", "input": ["text"]},
{"id": "vision-1", "input": ["text", "image"]},
],
)
repo = SimpleNamespace(get=lambda provider_id: provider if provider_id == 7 else None)
values: dict[str, str] = {}
with pytest.raises(ValueError, match="provider is not ready"):
ocr.set_ocr_settings(
values.__setitem__,
enabled=True,
backend="remote",
model="text-only",
provider_id="7",
provider_repo=repo,
)
ocr.set_ocr_settings(
values.__setitem__,
enabled=True,
backend="remote",
model="vision-1",
provider_id="7",
provider_repo=repo,
)
assert ocr.get_ocr_capability(values.get, repo)["usable"] is True
def test_image_and_blank_pdf_use_ocr(tmp_path: Path) -> None:
image = tmp_path / "scan.png"
image.write_bytes(b"not-decoded-by-the-fake")
pdf = tmp_path / "scan.pdf"
writer = PdfWriter()
writer.add_blank_page(width=72, height=72)
with pdf.open("wb") as output:
writer.write(output)
calls: list[Path] = []
def fake_ocr(path: Path) -> str:
calls.append(path)
return "recognized text"
assert parse_document(image, ocr=fake_ocr) == "recognized text"
assert parse_document(pdf, ocr=fake_ocr) == "recognized text"
assert calls == [image, pdf]
def test_text_pdf_does_not_require_ocr(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
pdf = tmp_path / "text.pdf"
pdf.write_bytes(b"placeholder")
class Page:
@staticmethod
def extract_text() -> str:
return "embedded text"
monkeypatch.setattr(
"pypdf.PdfReader",
lambda _path: SimpleNamespace(pages=[Page()]),
)
def unexpected_ocr(_path: Path) -> str:
raise AssertionError("OCR must not run for a text PDF")
assert parse_document(pdf, ocr=unexpected_ocr) == "embedded text"
def test_image_requires_enabled_ocr(tmp_path: Path) -> None:
image = tmp_path / "scan.jpg"
image.write_bytes(b"image")
with pytest.raises(RuntimeError, match="OCR is not enabled"):
parse_document(image)
def test_local_ocr_joins_rapidocr_text_lines(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
image = tmp_path / "scan.png"
image.write_bytes(b"image")
def engine(_data: bytes) -> SimpleNamespace:
return SimpleNamespace(txts=("第一行", "Second line"))
monkeypatch.setattr(ocr, "_rapidocr_engine", lambda: engine)
assert ocr._extract_local(image) == "第一行\nSecond line"
def test_remote_ocr_sends_image_block(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
image = tmp_path / "scan.png"
image.write_bytes(b"image")
messages: list[object] = []
class Model:
@staticmethod
def invoke(value: list[object]) -> SimpleNamespace:
messages.extend(value)
return SimpleNamespace(content="remote text")
monkeypatch.setattr(ocr, "build_probe_chat_model", lambda *_a, **_k: Model())
extractor = ocr._RemoteOcr(SimpleNamespace(), "vision")
assert extractor(image) == "remote text"
content = messages[0].content
assert content[1]["type"] == "image_url"
assert content[1]["image_url"]["url"].startswith("data:image/png;base64,")
def _remote_extractor(monkeypatch: pytest.MonkeyPatch, reply: object) -> ocr._RemoteOcr:
"""Build a ``_RemoteOcr`` whose model returns *reply* (str or a per-call sequence)."""
class Model:
@staticmethod
def invoke(_value: list[object]) -> SimpleNamespace:
if isinstance(reply, list):
return SimpleNamespace(content=reply.pop(0))
return SimpleNamespace(content=reply)
monkeypatch.setattr(ocr, "build_probe_chat_model", lambda *_a, **_k: Model())
return ocr._RemoteOcr(SimpleNamespace(), "vision")
@pytest.mark.parametrize(
"refusal",
[
"No image was attached. Please upload an image.",
"I don't see an image attached to your message. Please upload the image you'd "
"like me to transcribe, and I'll provide the exact transcription.",
"未收到图片,请上传图片后重试。",
],
)
def test_remote_ocr_ignores_no_image_refusal(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, refusal: str
) -> None:
"""A refusal is not source text: indexing it would mark the document ready with junk."""
image = tmp_path / "scan.png"
image.write_bytes(b"image")
assert _remote_extractor(monkeypatch, refusal)(image) == ""
def test_remote_ocr_keeps_long_page_that_mentions_uploading_an_image(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The refusal filter must not drop real transcriptions that happen to mention an image."""
image = tmp_path / "scan.png"
image.write_bytes(b"image")
page = (
"No image was attached. Please upload an image. "
+ "发票明细:办公用品 128.00 元,差旅费 340.00 元。" * 12
)
assert _remote_extractor(monkeypatch, page)(image) == page
def test_remote_ocr_skips_refusal_page_but_keeps_transcribed_page(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Refusals are dropped per page, so a partly readable document keeps its good pages."""
pdf = tmp_path / "scan.pdf"
pdf.write_bytes(b"%PDF-1.4")
monkeypatch.setattr(
ocr,
"_image_inputs",
lambda _path: iter([(b"page-1", "image/png"), (b"page-2", "image/png")]),
)
extractor = _remote_extractor(
monkeypatch,
["No image was attached. Please upload an image.", "第二页正文"],
)
assert extractor(pdf) == "第二页正文"