diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index d015636..c3ab307 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -44,6 +44,8 @@ from polygateway.types import ( Effort, EmbeddingResponse, LLMResponse, + OcrDocumentElement, + OcrDocumentResult, OcrLayoutElement, OcrLayoutResult, OcrTextResult, @@ -78,6 +80,8 @@ __all__ = [ "OpenAIChatCompletion", "ModelPrice", "OcrClient", + "OcrDocumentElement", + "OcrDocumentResult", "OcrLayoutElement", "OcrLayoutResult", "OcrSettings", diff --git a/src/polygateway/ports.py b/src/polygateway/ports.py index b8b9610..bb389a1 100644 --- a/src/polygateway/ports.py +++ b/src/polygateway/ports.py @@ -16,6 +16,8 @@ from .types import ( Effort, EmbeddingTransportResult, LLMResponse, + OcrDocumentResult, + OcrDocumentTransportResult, OcrLayoutResult, OcrLayoutTransportResult, OcrTextResult, @@ -102,6 +104,26 @@ class OcrLayoutPort(Protocol): 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 class OcrTransport(Protocol): """一次原始 OCR 调用的协议细节(M3 设计 §3.2);不含任何治理。 diff --git a/src/polygateway/types.py b/src/polygateway/types.py index aa5322a..5799fcc 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -276,13 +276,19 @@ class ThinkingObservation(StrEnum): UNKNOWN = "unknown" -CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout"] -"""遥测 `operation` 列的值域: **公开方法**四值,由调用点给定。 +CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout", "parse_document"] +"""遥测 `operation` 列的值域: **公开方法**五值,由调用点给定。 与 `PolyGatewayError.operation`(HTTP 子操作,如 `download_result`)是**两个语义**, 不做自动转换;链路上任何位置都不得读 `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"] """一行遥测描述的事件形态;旧行 NULL,不回填。 @@ -1189,6 +1195,10 @@ class OcrLayoutElement: type 为开放字符串(实测 table/image/text,不枚举锁死——零业务假设); bbox 为 OCR 原生页面坐标 (x1, y1, x2, y2),几何映射留业务侧(D9)。 + + 注意: `OcrDocumentElement` 继承本类并追加**无默认值**的 `text`。dataclass 要求 + 无默认字段不得排在带默认字段之后,故日后若给本类追加带默认值的字段,必须先评估 + 子类——否则 `OcrDocumentElement` 会在导入期抛 `TypeError`(#34 设计 §4.1)。 """ type: str @@ -1196,6 +1206,22 @@ class OcrLayoutElement: 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) class OcrTextResult: """一次治理 OCR 文本转录的统一响应(/ocr/text;M3 设计 §3.1)。 @@ -1232,6 +1258,31 @@ class OcrLayoutResult: """本次逻辑调用的统计快照(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/.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) class OcrTextTransportResult: """transport 单次 /ocr/text 调用产物;治理字段由 OcrClient 补齐。""" @@ -1249,6 +1300,16 @@ class OcrLayoutTransportResult: 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) class EmbeddingTransportResult: """一次原始 embedding 调用的解析结果(M2 设计 §7.2;transport → client)。""" diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 2125e52..f83157d 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -22,6 +22,8 @@ def test_ocr_public_surface_exported(): "OcrTextResult", "OcrLayoutResult", "OcrLayoutElement", + "OcrDocumentResult", + "OcrDocumentElement", ): assert hasattr(polygateway, name), name assert name in polygateway.__all__, name diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index 7e352b5..da359cb 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -12,6 +12,7 @@ from polygateway.ports import ( GateState, GateUpdate, Middleware, + OcrDocumentTransport, OcrTransport, Permit, ProviderGate, @@ -193,6 +194,7 @@ class TestReasoningTierIsOnlyOnTheChatPort: (EmbeddingTransport, "embed"), (OcrTransport, "recognize_text"), (OcrTransport, "parse_layout"), + (OcrDocumentTransport, "parse_document"), ], ) def test_other_transports_have_no_reasoning_tier(self, protocol, method): @@ -372,6 +374,34 @@ class TestOcrPorts: assert isinstance(GoodClient(), OcrLayoutPort) 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): from polygateway.ports import OcrTransport diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 3172361..a891f2b 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -6,6 +6,7 @@ import inspect import pytest from polygateway.types import ( + CALL_OPERATIONS, USAGE_SOURCES, BackpressurePolicy, BreakerConfig, @@ -13,6 +14,10 @@ from polygateway.types import ( Effort, GlobalLimits, LLMResponse, + OcrDocumentElement, + OcrDocumentResult, + OcrDocumentTransportResult, + OcrLayoutElement, RetryPolicy, SourceConfig, ThinkingObservation, @@ -849,3 +854,61 @@ class _FakeMonotonic: def advance(self, seconds: float) -> None: 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