test: add live PDF document parsing checks
Slow, opt-in, serial single-page documents built in the test itself. Covers the document entrance, the PDF text-recognition fix, and that corrupt documents do not open the breaker.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""MonkeyOCR 整份文档活体验收(#34 计划 T8)。
|
||||
|
||||
**成败取决于外部服务当下状态**,按 CLAUDE.md §4.6 一律标 `slow`: 默认套件不收集,发布清单
|
||||
第 4 步按 diff 交集选跑(`pytest tests/integration/test_monkey_ocr_document_live.py -m slow`)。
|
||||
服务与医院线共用——所以每条用例只发**单份极小文档**、串行、不压测。
|
||||
|
||||
文档字节由本文件手写构造(最小合法 PDF,无第三方内容、无新依赖)。
|
||||
不依赖 `data/soak` 图片语料,故**不**并入带语料 `skipif` 的 `test_monkey_ocr_live.py`。
|
||||
服务不可达即 FAIL——验收必打真实,不 skip。
|
||||
"""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from polygateway.errors import RequestRejectedError
|
||||
from polygateway.ocr import OcrClient
|
||||
|
||||
pytestmark = pytest.mark.slow
|
||||
|
||||
_PRIMARY = "http://10.77.0.20:7866"
|
||||
_MARKER = "POLYGATEWAY SYNTHETIC SAMPLE"
|
||||
_OUTPUTS = Path("tests/outputs/monkey_ocr_document")
|
||||
|
||||
|
||||
def _minimal_pdf(text: str) -> bytes:
|
||||
"""5 个对象的最小合法单页 PDF;xref 偏移按实际字节计算。`text` 不得含括号。"""
|
||||
stream = f"BT /F1 28 Tf 40 700 Td ({text}) Tj ET".encode()
|
||||
objects = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R "
|
||||
b"/Resources << /Font << /F1 5 0 R >> >> >>",
|
||||
b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
]
|
||||
out = bytearray(b"%PDF-1.4\n")
|
||||
offsets = []
|
||||
for number, body in enumerate(objects, start=1):
|
||||
offsets.append(len(out))
|
||||
out += b"%d 0 obj\n" % number + body + b"\nendobj\n"
|
||||
xref_at = len(out)
|
||||
out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objects) + 1)
|
||||
for offset in offsets:
|
||||
out += b"%010d 00000 n \n" % offset
|
||||
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (
|
||||
len(objects) + 1,
|
||||
xref_at,
|
||||
)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _env(**extra: str) -> dict[str, str]:
|
||||
env = {
|
||||
"LLM_MAX_RETRIES": "3",
|
||||
"LLM_RETRY_BASE_DELAY": "1.0",
|
||||
"LLM_RETRY_MAX_DELAY": "5.0",
|
||||
# 阈值 2: 坏文档若被误记成源故障,两份就足以开路——下面第三条用例据此判别
|
||||
"LLM_CIRCUIT_BREAKER_THRESHOLD": "2",
|
||||
"LLM_CIRCUIT_BREAKER_COOLDOWN": "30",
|
||||
"PGW_CACHE_BACKEND": "none",
|
||||
"PGW_TELEMETRY_BACKEND": "none",
|
||||
"OCR__MONKEY__1__BASE_URL": _PRIMARY,
|
||||
"OCR__MONKEY__1__API_KEY": "none",
|
||||
"OCR__MONKEY__1__MODEL": "monkey-ocr",
|
||||
"OCR__MONKEY__1__TIMEOUT_S": "300",
|
||||
"OCR__MONKEY__1__MAX_CONCURRENCY": "1", # 与医院线共用,串行
|
||||
# 服务在 LAN,开发机的系统代理会对内网地址回 403
|
||||
"OCR__MONKEY__1__TRUST_ENV": "false",
|
||||
}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
|
||||
def _record(name: str, lines: list[str]) -> None:
|
||||
"""结构化 Markdown 证据(CLAUDE.md §4.6);tests/outputs 不提交。"""
|
||||
_OUTPUTS.mkdir(parents=True, exist_ok=True)
|
||||
stamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
(_OUTPUTS / f"{name}_{stamp}.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
class TestLiveDocument:
|
||||
async def test_parse_document_returns_pages_and_text(self):
|
||||
async with OcrClient.from_env("OCR", env=_env()) as client:
|
||||
result = await client.parse_document(_minimal_pdf(_MARKER))
|
||||
assert len(result.page_sizes) >= 1 and all(w > 0 and h > 0 for w, h in result.page_sizes)
|
||||
assert result.elements, "合成 PDF 应至少检出一个块"
|
||||
# 正向断言: 空串不能通过。OCR 对大号印刷体应稳定识别出标记词
|
||||
assert "SYNTHETIC" in result.markdown.upper()
|
||||
assert any("SYNTHETIC" in e.text.upper() for e in result.elements)
|
||||
assert all(e.page_index == 0 for e in result.elements)
|
||||
_record(
|
||||
"parse_document",
|
||||
[
|
||||
"# parse_document 活体",
|
||||
f"- source: {result.source_name} latency_ms: {result.latency_ms}",
|
||||
f"- pages: {len(result.page_sizes)} elements: {len(result.elements)}",
|
||||
f"- types: {sorted({e.type for e in result.elements})}",
|
||||
f"- markdown: {result.markdown[:200]!r}",
|
||||
],
|
||||
)
|
||||
|
||||
async def test_recognize_text_on_pdf_is_not_the_hallucination(self):
|
||||
"""E4: PDF 以 image.jpg 上传时 `/ocr/text` 返回 success 的幻觉文本。标签修正后应返回全文。"""
|
||||
async with OcrClient.from_env("OCR", env=_env()) as client:
|
||||
result = await client.recognize_text(_minimal_pdf(_MARKER))
|
||||
assert result.text.strip()
|
||||
assert "single character" not in result.text.lower()
|
||||
assert "SYNTHETIC" in result.text.upper()
|
||||
_record(
|
||||
"recognize_text_pdf", ["# recognize_text(PDF) 活体", f"- text: {result.text[:200]!r}"]
|
||||
)
|
||||
|
||||
async def test_corrupt_documents_do_not_open_the_breaker(self):
|
||||
"""确定性坏输入归「请求被拒」,熔断记成功不计窗口。
|
||||
|
||||
阈值 2: 若坏文档被误记成源故障(旧行为: 500 → Transient → 重试并计熔断),
|
||||
连续两份就会开路,第三次的有效文档会被 `fail_fast` 拒绝。
|
||||
"""
|
||||
corrupt = [b"%PDF-1.4\nnot really a pdf\n", b"%PDF-1.4\n" + b"garbage" * 50]
|
||||
async with OcrClient.from_env("OCR", env=_env()) as client:
|
||||
for bad in corrupt:
|
||||
with pytest.raises(RequestRejectedError) as ei:
|
||||
await client.parse_document(bad)
|
||||
assert ei.value.status_code == 500 # 服务有响应: 按「响应即健康」记成功
|
||||
result = await client.parse_document(_minimal_pdf(_MARKER))
|
||||
assert result.elements
|
||||
_record(
|
||||
"corrupt_documents",
|
||||
[
|
||||
"# 坏文档不开熔断 活体",
|
||||
f"- 两份坏文档均 RequestRejected(500); 随后有效文档成功, elements={len(result.elements)}",
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user