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:
@@ -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",
|
||||||
|
|||||||
@@ -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);不含任何治理。
|
||||||
|
|||||||
@@ -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 原文,**含悬空的 ``
|
||||||
|
图片引用**——库不外传图片资源。服务端会丢弃脚注、页眉页脚与页码,这些内容
|
||||||
|
既不在 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)。"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user