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