cb1e08f1f3
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.
136 lines
6.0 KiB
Python
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)}",
|
|
],
|
|
)
|