refactor(server): user_agent/steward/ocr 等服务重构并适配关联任务
- user_agent 拆分 application/locations/knowledge/response/review 四个子模块,接入申请位置语义与关联草稿分支 - steward planner/runtime/slot/plan_builder 决策链路重构,travel_reimbursement_calculator/orchestrator_expense_query 适配 - ocr/document_preview/document_intelligence/receipt_folder 复用预览与资产缓存,expense_claim_draft_flow/application_handoff 适配 - pyproject.toml 新增依赖,paddleocr bootstrap 脚本与 server_start.sh 调整 - 更新差旅/交通/通信等财务规则表,同步 document_intelligence/ocr/receipt_folder/user_agent 等测试
This commit is contained in:
@@ -5,19 +5,23 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.services import document_preview
|
||||
from app.services.ocr import OcrService
|
||||
|
||||
|
||||
def test_ocr_runtime_installers_include_poppler_cjk_data() -> None:
|
||||
def test_ocr_runtime_installers_include_cjk_safe_pdf_rendering_tools() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
dependency_sources = [
|
||||
repo_root / "docker-compose.yml",
|
||||
repo_root / "docker-compose.full.yml",
|
||||
repo_root / "server" / "scripts" / "bootstrap_paddleocr_mobile.sh",
|
||||
repo_root / "server" / "scripts" / "bootstrap_paddleocr_gpu.sh",
|
||||
]
|
||||
|
||||
for path in dependency_sources:
|
||||
assert "poppler-data" in path.read_text(encoding="utf-8")
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert "poppler-data" in content
|
||||
assert "mupdf-tools" in content
|
||||
|
||||
|
||||
def test_ocr_service_uses_worker_runtime_and_keeps_unsupported_files_as_warnings(
|
||||
@@ -163,6 +167,7 @@ def test_ocr_service_passes_configured_device_to_worker(
|
||||
text: bool,
|
||||
timeout: int,
|
||||
check: bool,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
captured_commands.append(command)
|
||||
return subprocess.CompletedProcess(
|
||||
@@ -194,12 +199,12 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> tuple[list[Path], bool]:
|
||||
first = output_dir / "page-1.png"
|
||||
second = output_dir / "page-2.png"
|
||||
first.write_bytes(b"fake-page-1")
|
||||
second.write_bytes(b"fake-page-2")
|
||||
return [first, second]
|
||||
return [first, second], True
|
||||
|
||||
def fake_invoke_worker(
|
||||
self,
|
||||
@@ -281,26 +286,143 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
|
||||
assert recognized.lines[1].page_index == 1
|
||||
|
||||
|
||||
def test_ocr_service_uses_pdf_text_layer_without_worker_runtime(
|
||||
def test_ocr_service_rejects_pdf_ocr_when_rendered_image_fonts_are_broken(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||
page = output_dir / "page-1.png"
|
||||
page.write_bytes(b"fake-rendered-page")
|
||||
return [page]
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> tuple[list[Path], bool]:
|
||||
raise RuntimeError("PDF 转图片失败:检测到中文字体映射缺失,未生成可 OCR 的图片。")
|
||||
|
||||
def fail_resolve_python(self) -> str:
|
||||
raise AssertionError("PDF 文本层可用时不应强制解析 OCR worker。")
|
||||
|
||||
def fail_invoke_worker(self, **kwargs) -> dict:
|
||||
raise AssertionError("PDF 文本层可用时不应调用 OCR worker。")
|
||||
def fake_invoke_worker(
|
||||
self,
|
||||
*,
|
||||
python_bin: str,
|
||||
worker_path: str,
|
||||
input_paths: list[Path],
|
||||
) -> dict:
|
||||
raise AssertionError("PDF 转图片已确认丢中文时,不应继续调用 OCR worker。")
|
||||
|
||||
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||
monkeypatch.setattr(OcrService, "_resolve_python_bin", fail_resolve_python)
|
||||
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
|
||||
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
|
||||
monkeypatch.setattr(OcrService, "_convert_pdf_to_images", fake_convert_pdf_to_images)
|
||||
monkeypatch.setattr(OcrService, "_invoke_worker", fail_invoke_worker)
|
||||
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
result = OcrService().recognize_files(
|
||||
[
|
||||
("2月20_武汉-上海.pdf", b"%PDF-1.7 fake", "application/pdf"),
|
||||
]
|
||||
)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
failed = result.documents[0]
|
||||
assert failed.line_count == 0
|
||||
assert failed.preview_kind == ""
|
||||
assert failed.preview_data_url == ""
|
||||
assert failed.warnings == ["PDF 转图片失败:检测到中文字体映射缺失,未生成可 OCR 的图片。"]
|
||||
|
||||
|
||||
def test_ocr_pdf_conversion_tries_next_renderer_when_poppler_font_mapping_fails(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
output_dir = tmp_path / "pages"
|
||||
output_dir.mkdir()
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_run(
|
||||
command: list[str],
|
||||
*,
|
||||
capture_output: bool,
|
||||
text: bool,
|
||||
timeout: int,
|
||||
check: bool,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
calls.append(Path(command[0]).name)
|
||||
if Path(command[0]).name == "pdftoppm":
|
||||
(output_dir / "page-1.png").write_bytes(b"broken-preview")
|
||||
return subprocess.CompletedProcess(
|
||||
args=command,
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr="Syntax Error: Missing language pack for 'Adobe-GB1' mapping",
|
||||
)
|
||||
(output_dir / "page-1.png").write_bytes(b"rendered-with-chinese")
|
||||
return subprocess.CompletedProcess(
|
||||
args=command,
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
document_preview.shutil,
|
||||
"which",
|
||||
lambda name: f"/usr/bin/{name}" if name in {"pdftoppm", "mutool"} else None,
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
pages, preview_usable = OcrService()._convert_pdf_to_images(
|
||||
pdf_path=tmp_path / "ticket.pdf",
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
assert pages == [output_dir / "page-1.png"]
|
||||
assert preview_usable is True
|
||||
assert calls == ["pdftoppm", "mutool"]
|
||||
|
||||
|
||||
def test_ocr_service_invokes_worker_even_when_pdf_text_layer_is_usable(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
calls = {"worker": 0}
|
||||
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> tuple[list[Path], bool]:
|
||||
page = output_dir / "page-1.png"
|
||||
page.write_bytes(b"fake-rendered-page")
|
||||
return [page], True
|
||||
|
||||
def fake_invoke_worker(
|
||||
self,
|
||||
*,
|
||||
python_bin: str,
|
||||
worker_path: str,
|
||||
input_paths: list[Path],
|
||||
) -> dict:
|
||||
calls["worker"] += 1
|
||||
return {
|
||||
"engine": "paddleocr_mobile",
|
||||
"model": "PP-OCRv5_mobile",
|
||||
"documents": [
|
||||
{
|
||||
"input_path": str(input_paths[0]),
|
||||
"engine": "paddleocr_mobile",
|
||||
"model": "PP-OCRv5_mobile",
|
||||
"text": "电子发票(铁路电子客票) 武汉站 上海虹桥站 G458 票价 ¥354.00",
|
||||
"summary": "铁路电子客票",
|
||||
"avg_score": 0.95,
|
||||
"line_count": 1,
|
||||
"page_count": 1,
|
||||
"warnings": [],
|
||||
"lines": [
|
||||
{
|
||||
"text": "电子发票(铁路电子客票) 武汉站 上海虹桥站 G458 票价 ¥354.00",
|
||||
"score": 0.95,
|
||||
"box": [[1, 2], [10, 2], [10, 8], [1, 8]],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
|
||||
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
|
||||
monkeypatch.setattr(OcrService, "_convert_pdf_to_images", fake_convert_pdf_to_images)
|
||||
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
|
||||
monkeypatch.setattr(
|
||||
OcrService,
|
||||
"_extract_pdf_text_layer",
|
||||
@@ -326,9 +448,9 @@ def test_ocr_service_uses_pdf_text_layer_without_worker_runtime(
|
||||
|
||||
recognized = result.documents[0]
|
||||
assert result.success_count == 1
|
||||
assert calls["worker"] == 1
|
||||
assert recognized.document_type == "train_ticket"
|
||||
assert "电子发票(铁路电子客票)" in recognized.text
|
||||
assert "电子客票号:6580061086021391007342026" in recognized.text
|
||||
assert any(field.label == "金额" and field.value == "354元" for field in recognized.document_fields)
|
||||
assert recognized.preview_kind == "image"
|
||||
assert recognized.preview_data_url.startswith("data:image/png;base64,")
|
||||
@@ -392,14 +514,22 @@ def test_ocr_service_reuses_cached_document_for_same_content(
|
||||
assert second.documents[0].summary == first.documents[0].summary
|
||||
|
||||
|
||||
def test_ocr_cache_key_includes_pdf_render_pipeline_version() -> None:
|
||||
cache_key = OcrService()._build_cache_key(b"same-pdf-content")
|
||||
|
||||
assert "pdf-image-ocr:" in cache_key
|
||||
assert document_preview.DocumentPreviewAssets.PDF_RENDERER_ID in cache_key
|
||||
assert "no-pdf-direct" in cache_key
|
||||
|
||||
|
||||
def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> tuple[list[Path], bool]:
|
||||
page = output_dir / "page-1.png"
|
||||
page.write_bytes(b"fake-page")
|
||||
return [page]
|
||||
return [page], True
|
||||
|
||||
def fake_invoke_worker(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user