feat: add OCR document types and ports

OcrDocumentElement extends the layout element with text; OcrDocumentResult stays independent because the parent's defaulted call_stats forbids a required markdown field. OcrDocumentTransport is a separate Protocol so existing OcrTransport implementers keep passing isinstance.
This commit is contained in:
2026-10-21 23:20:53 -04:00
parent b31df842a8
commit 398b16e136
6 changed files with 185 additions and 3 deletions
+4
View File
@@ -44,6 +44,8 @@ from polygateway.types import (
Effort, Effort,
EmbeddingResponse, EmbeddingResponse,
LLMResponse, LLMResponse,
OcrDocumentElement,
OcrDocumentResult,
OcrLayoutElement, OcrLayoutElement,
OcrLayoutResult, OcrLayoutResult,
OcrTextResult, OcrTextResult,
@@ -78,6 +80,8 @@ __all__ = [
"OpenAIChatCompletion", "OpenAIChatCompletion",
"ModelPrice", "ModelPrice",
"OcrClient", "OcrClient",
"OcrDocumentElement",
"OcrDocumentResult",
"OcrLayoutElement", "OcrLayoutElement",
"OcrLayoutResult", "OcrLayoutResult",
"OcrSettings", "OcrSettings",
+22
View File
@@ -16,6 +16,8 @@ from .types import (
Effort, Effort,
EmbeddingTransportResult, EmbeddingTransportResult,
LLMResponse, LLMResponse,
OcrDocumentResult,
OcrDocumentTransportResult,
OcrLayoutResult, OcrLayoutResult,
OcrLayoutTransportResult, OcrLayoutTransportResult,
OcrTextResult, OcrTextResult,
@@ -102,6 +104,26 @@ class OcrLayoutPort(Protocol):
async def parse_layout(self, image: bytes) -> OcrLayoutResult: ... async def parse_layout(self, image: bytes) -> OcrLayoutResult: ...
@runtime_checkable
class OcrDocumentPort(Protocol):
"""OCR 整份文档解析端口(#34;对应 MonkeyOCR POST /parse → GET ZIP,PDF 或图片)。"""
async def parse_document(self, document: bytes) -> OcrDocumentResult: ...
@runtime_checkable
class OcrDocumentTransport(Protocol):
"""整份文档解析的协议细节(#34 设计 §4.2);不含任何治理。
**独立于 `OcrTransport`**: 往那个 `runtime_checkable` Protocol 加成员,会让所有
现有实现(测试替身、下游自带 fake)在 `isinstance` 上一夜之间不合格。
"""
async def parse_document(
self, *, document: bytes, source: SourceConfig, call_id: str
) -> OcrDocumentTransportResult: ...
@runtime_checkable @runtime_checkable
class OcrTransport(Protocol): class OcrTransport(Protocol):
"""一次原始 OCR 调用的协议细节(M3 设计 §3.2);不含任何治理。 """一次原始 OCR 调用的协议细节(M3 设计 §3.2);不含任何治理。
+64 -3
View File
@@ -276,13 +276,19 @@ class ThinkingObservation(StrEnum):
UNKNOWN = "unknown" UNKNOWN = "unknown"
CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout"] CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout", "parse_document"]
"""遥测 `operation` 列的值域: **公开方法**四值,由调用点给定。 """遥测 `operation` 列的值域: **公开方法**五值,由调用点给定。
与 `PolyGatewayError.operation`(HTTP 子操作,如 `download_result`)是**两个语义**, 与 `PolyGatewayError.operation`(HTTP 子操作,如 `download_result`)是**两个语义**,
不做自动转换;链路上任何位置都不得读 `exc.operation` 来填本列(设计 §5 I1/I2)。""" 不做自动转换;链路上任何位置都不得读 `exc.operation` 来填本列(设计 §5 I1/I2)。"""
CALL_OPERATIONS: tuple[CallOperation, ...] = ("chat", "embed", "recognize_text", "parse_layout") CALL_OPERATIONS: tuple[CallOperation, ...] = (
"chat",
"embed",
"recognize_text",
"parse_layout",
"parse_document",
)
EventKind = Literal["attempt", "cache_hit", "terminal_failure"] EventKind = Literal["attempt", "cache_hit", "terminal_failure"]
"""一行遥测描述的事件形态;旧行 NULL,不回填。 """一行遥测描述的事件形态;旧行 NULL,不回填。
@@ -1189,6 +1195,10 @@ class OcrLayoutElement:
type 为开放字符串(实测 table/image/text,不枚举锁死——零业务假设); type 为开放字符串(实测 table/image/text,不枚举锁死——零业务假设);
bbox 为 OCR 原生页面坐标 (x1, y1, x2, y2),几何映射留业务侧(D9)。 bbox 为 OCR 原生页面坐标 (x1, y1, x2, y2),几何映射留业务侧(D9)。
注意: `OcrDocumentElement` 继承本类并追加**无默认值**的 `text`。dataclass 要求
无默认字段不得排在带默认字段之后,故日后若给本类追加带默认值的字段,必须先评估
子类——否则 `OcrDocumentElement` 会在导入期抛 `TypeError`(#34 设计 §4.1)。
""" """
type: str type: str
@@ -1196,6 +1206,22 @@ class OcrLayoutElement:
page_index: int page_index: int
@dataclass(frozen=True)
class OcrDocumentElement(OcrLayoutElement):
"""文档解析的逐块结果(#34 设计 §4.1): 版面元素 + 该块的文字内容。
`text` 的含义随 `type`,由 `parse_document` 的解析规则固定:
`text`/`title` 为纯文本(行内公式保留 `$…$`);`interline_equation` 为裸 LaTeX
(无 `$$` 定界);`table` 为 HTML 表体;`image` 为空串。服务将来若给出带 caption
/footnote 的嵌套子块,它们作为独立元素紧随父块输出,`type` 取子块类型。
继承而非组合: 文档元素"是"版面元素加内容,按 bbox 裁剪的下游函数可同时处理两者。
代价是 dataclass 的 `__eq__` 要求同类,`OcrDocumentElement != OcrLayoutElement`。
"""
text: str
@dataclass(frozen=True) @dataclass(frozen=True)
class OcrTextResult: class OcrTextResult:
"""一次治理 OCR 文本转录的统一响应(/ocr/text;M3 设计 §3.1)。 """一次治理 OCR 文本转录的统一响应(/ocr/text;M3 设计 §3.1)。
@@ -1232,6 +1258,31 @@ class OcrLayoutResult:
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。""" """本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
@dataclass(frozen=True)
class OcrDocumentResult:
"""一次治理整份文档解析的统一响应(/parse → ZIP;#34 设计 §4.1)。
**独立定义,不继承 `OcrLayoutResult`**: 父类末尾的 `call_stats` 带默认值,
子类追加无默认字段 `markdown` 会在导入期 `TypeError`。
elements 按服务阅读序排列,页内按页序;page_sizes 按 page_index 索引(零基)。
`markdown` 是服务生成的整篇 Markdown 原文,**含悬空的 `![](images/<sha>.jpg)`
图片引用**——库不外传图片资源。服务端会丢弃脚注、页眉页脚与页码,这些内容
既不在 elements 也不在 markdown 里,库无从补偿。
"""
elements: list[OcrDocumentElement]
page_sizes: list[tuple[float, float]]
markdown: str
source_name: str
usage: Usage
latency_ms: int
call_id: str
raw: dict[str, Any]
call_stats: CallStats | None = None
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class OcrTextTransportResult: class OcrTextTransportResult:
"""transport 单次 /ocr/text 调用产物;治理字段由 OcrClient 补齐。""" """transport 单次 /ocr/text 调用产物;治理字段由 OcrClient 补齐。"""
@@ -1249,6 +1300,16 @@ class OcrLayoutTransportResult:
raw: dict[str, Any] raw: dict[str, Any]
@dataclass(frozen=True)
class OcrDocumentTransportResult:
"""transport 单次整份文档两段调用的产物;治理字段由 OcrClient 补齐。"""
elements: list[OcrDocumentElement]
page_sizes: list[tuple[float, float]]
markdown: str
raw: dict[str, Any]
@dataclass(frozen=True) @dataclass(frozen=True)
class EmbeddingTransportResult: class EmbeddingTransportResult:
"""一次原始 embedding 调用的解析结果(M2 设计 §7.2;transport → client)。""" """一次原始 embedding 调用的解析结果(M2 设计 §7.2;transport → client)。"""
+2
View File
@@ -22,6 +22,8 @@ def test_ocr_public_surface_exported():
"OcrTextResult", "OcrTextResult",
"OcrLayoutResult", "OcrLayoutResult",
"OcrLayoutElement", "OcrLayoutElement",
"OcrDocumentResult",
"OcrDocumentElement",
): ):
assert hasattr(polygateway, name), name assert hasattr(polygateway, name), name
assert name in polygateway.__all__, name assert name in polygateway.__all__, name
+30
View File
@@ -12,6 +12,7 @@ from polygateway.ports import (
GateState, GateState,
GateUpdate, GateUpdate,
Middleware, Middleware,
OcrDocumentTransport,
OcrTransport, OcrTransport,
Permit, Permit,
ProviderGate, ProviderGate,
@@ -193,6 +194,7 @@ class TestReasoningTierIsOnlyOnTheChatPort:
(EmbeddingTransport, "embed"), (EmbeddingTransport, "embed"),
(OcrTransport, "recognize_text"), (OcrTransport, "recognize_text"),
(OcrTransport, "parse_layout"), (OcrTransport, "parse_layout"),
(OcrDocumentTransport, "parse_document"),
], ],
) )
def test_other_transports_have_no_reasoning_tier(self, protocol, method): def test_other_transports_have_no_reasoning_tier(self, protocol, method):
@@ -372,6 +374,34 @@ class TestOcrPorts:
assert isinstance(GoodClient(), OcrLayoutPort) assert isinstance(GoodClient(), OcrLayoutPort)
assert isinstance(GoodTransport(), OcrTransport) assert isinstance(GoodTransport(), OcrTransport)
def test_document_ports_are_runtime_checkable(self):
from polygateway.ports import OcrDocumentPort, OcrDocumentTransport
class GoodClient:
async def parse_document(self, document): ...
class GoodTransport:
async def parse_document(self, *, document, source, call_id): ...
class Empty: ...
assert isinstance(GoodClient(), OcrDocumentPort)
assert isinstance(GoodTransport(), OcrDocumentTransport)
assert not isinstance(Empty(), OcrDocumentPort)
assert not isinstance(Empty(), OcrDocumentTransport)
def test_ocr_transport_members_are_unchanged(self):
"""新能力走独立 Protocol: 往旧 runtime_checkable Protocol 加成员,会让所有
现有实现(测试替身、下游自带 fake)在 isinstance 上一夜之间不合格。"""
from polygateway.ports import OcrTransport
members = {
name
for name in vars(OcrTransport)
if not name.startswith("_") and callable(getattr(OcrTransport, name))
}
assert members == {"recognize_text", "parse_layout", "check_health"}
def test_missing_method_rejected(self): def test_missing_method_rejected(self):
from polygateway.ports import OcrTransport from polygateway.ports import OcrTransport
+63
View File
@@ -6,6 +6,7 @@ import inspect
import pytest import pytest
from polygateway.types import ( from polygateway.types import (
CALL_OPERATIONS,
USAGE_SOURCES, USAGE_SOURCES,
BackpressurePolicy, BackpressurePolicy,
BreakerConfig, BreakerConfig,
@@ -13,6 +14,10 @@ from polygateway.types import (
Effort, Effort,
GlobalLimits, GlobalLimits,
LLMResponse, LLMResponse,
OcrDocumentElement,
OcrDocumentResult,
OcrDocumentTransportResult,
OcrLayoutElement,
RetryPolicy, RetryPolicy,
SourceConfig, SourceConfig,
ThinkingObservation, ThinkingObservation,
@@ -849,3 +854,61 @@ class _FakeMonotonic:
def advance(self, seconds: float) -> None: def advance(self, seconds: float) -> None:
self.t += seconds self.t += seconds
class TestOcrDocumentTypes:
"""#34 设计 §4.1: 文档元素是版面元素 + 内容;结果独立定义。"""
@staticmethod
def _element(**overrides):
base = {"type": "text", "bbox": (0.0, 0.0, 1.0, 1.0), "page_index": 0, "text": "x"}
base.update(overrides)
return OcrDocumentElement(**base)
def test_document_element_is_a_layout_element(self):
"""继承的价值: 按 bbox 裁剪的下游函数可以同时吃两种元素。"""
assert isinstance(self._element(), OcrLayoutElement)
def test_document_element_never_equals_a_layout_element(self):
"""dataclass 的 `__eq__` 要求同类——已知语义,钉死以免下游拿版面元素去比对文档结果。"""
layout = OcrLayoutElement(type="text", bbox=(0.0, 0.0, 1.0, 1.0), page_index=0)
assert self._element() != layout
def test_new_types_are_frozen(self):
element = self._element()
with pytest.raises(dataclasses.FrozenInstanceError):
element.text = "y" # type: ignore[misc]
transport = OcrDocumentTransportResult(elements=[], page_sizes=[], markdown="", raw={})
with pytest.raises(dataclasses.FrozenInstanceError):
transport.markdown = "y" # type: ignore[misc]
def test_result_is_not_a_subclass_of_layout_result(self):
"""父类末尾 `call_stats` 带默认值,子类无默认字段 `markdown` 会在导入期 TypeError。"""
from polygateway.types import OcrLayoutResult
assert not issubclass(OcrDocumentResult, OcrLayoutResult)
def test_result_carries_provenance_five_plus_markdown(self):
fields = [f.name for f in dataclasses.fields(OcrDocumentResult)]
assert fields == [
"elements",
"page_sizes",
"markdown",
"source_name",
"usage",
"latency_ms",
"call_id",
"raw",
"call_stats",
]
class TestCallOperations:
def test_call_operations_matches_the_literal(self):
"""`CALL_OPERATIONS` 此前没有任何读者,与 `CallOperation` 漂移也无人发现。"""
from typing import get_args
from polygateway.types import CallOperation
assert get_args(CallOperation) == CALL_OPERATIONS
assert "parse_document" in CALL_OPERATIONS