From c4dd403e0f86167aea304006b3d501d76776131c Mon Sep 17 00:00:00 2001 From: iomgaa Date: Thu, 22 Oct 2026 00:14:54 -0400 Subject: [PATCH] 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. --- .../test_monkey_ocr_document_live.py | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 tests/integration/test_monkey_ocr_document_live.py diff --git a/tests/integration/test_monkey_ocr_document_live.py b/tests/integration/test_monkey_ocr_document_live.py new file mode 100644 index 0000000..4c1d652 --- /dev/null +++ b/tests/integration/test_monkey_ocr_document_live.py @@ -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)}", + ], + )