Files
PolyGateway/tests/integration/test_monkey_ocr_document_live.py
T
iomgaa cb1e08f1f3 fix: close adversarial review findings on document parsing
Malformed download URLs are rejected before the second request, error bodies are read only up to a bound, the telemetry summary keeps structural dispatch for duck-typed results, and the BMP magic now requires a valid DIB header. Docs and design are aligned with the implementation, and new test wording is neutral.
2026-10-22 02:09:06 -04:00

136 lines
6.0 KiB
Python

"""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)}",
],
)