From 118a9abd09d338e258c9f6cbc6da1f0a960e88fe Mon Sep 17 00:00:00 2001 From: Dirk Riemann Date: Sat, 18 Apr 2026 11:08:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(clients):=20OCRClient=20+=20GenAIClient=20?= =?UTF-8?q?protocols=20+=20fakes=20(spec=20=C2=A76.2,=20=C2=A76.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two Protocol-based client contracts the pipeline steps depend on, plus test-oriented fakes. Real engines (Surya, Ollama) land in Chunk 4. - ix.ocr.client.OCRClient — runtime_checkable Protocol with async ocr(). - ix.genai.client.GenAIClient — runtime_checkable Protocol with async invoke(); GenAIInvocationResult + GenAIUsage dataclasses carry the parsed model, token usage, and model name. - FakeOCRClient / FakeGenAIClient: return canned results; both expose a raise_on_call hook for error-path tests. 8 unit tests across tests/unit/test_ocr_fake.py + test_genai_fake.py confirm protocol conformance, canned-return behaviour, usage/model-name defaults, and raise_on_call propagation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ix/genai/__init__.py | 18 +++++++++ src/ix/genai/client.py | 72 +++++++++++++++++++++++++++++++++++ src/ix/genai/fake.py | 61 +++++++++++++++++++++++++++++ src/ix/ocr/__init__.py | 13 +++++++ src/ix/ocr/client.py | 32 ++++++++++++++++ src/ix/ocr/fake.py | 40 +++++++++++++++++++ tests/unit/test_genai_fake.py | 55 ++++++++++++++++++++++++++ tests/unit/test_ocr_fake.py | 57 +++++++++++++++++++++++++++ 8 files changed, 348 insertions(+) create mode 100644 src/ix/genai/__init__.py create mode 100644 src/ix/genai/client.py create mode 100644 src/ix/genai/fake.py create mode 100644 src/ix/ocr/__init__.py create mode 100644 src/ix/ocr/client.py create mode 100644 src/ix/ocr/fake.py create mode 100644 tests/unit/test_genai_fake.py create mode 100644 tests/unit/test_ocr_fake.py diff --git a/src/ix/genai/__init__.py b/src/ix/genai/__init__.py new file mode 100644 index 0000000..b73c434 --- /dev/null +++ b/src/ix/genai/__init__.py @@ -0,0 +1,18 @@ +"""GenAI subsystem: protocol + fake client + invocation-result dataclasses. + +Real backends (Ollama, etc.) plug in behind :class:`GenAIClient`. The MVP +ships only :class:`FakeGenAIClient` from this package; the real Ollama +client lands in Chunk 4. +""" + +from __future__ import annotations + +from ix.genai.client import GenAIClient, GenAIInvocationResult, GenAIUsage +from ix.genai.fake import FakeGenAIClient + +__all__ = [ + "FakeGenAIClient", + "GenAIClient", + "GenAIInvocationResult", + "GenAIUsage", +] diff --git a/src/ix/genai/client.py b/src/ix/genai/client.py new file mode 100644 index 0000000..4dd7c4d --- /dev/null +++ b/src/ix/genai/client.py @@ -0,0 +1,72 @@ +"""GenAIClient Protocol + invocation-result dataclasses (spec §6.3). + +Structural typing: any object with an async +``invoke(request_kwargs, response_schema) -> GenAIInvocationResult`` +method satisfies the Protocol. :class:`~ix.pipeline.genai_step.GenAIStep` +depends on the Protocol; swapping ``FakeGenAIClient`` in tests for +``OllamaClient`` in prod stays a wiring change. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable + +from pydantic import BaseModel + + +@dataclass(slots=True) +class GenAIUsage: + """Token counters returned by the LLM backend. + + Both fields default to 0 so fakes / degraded backends can omit them. + """ + + prompt_tokens: int = 0 + completion_tokens: int = 0 + + +@dataclass(slots=True) +class GenAIInvocationResult: + """One LLM call's full output. + + Attributes + ---------- + parsed: + The Pydantic instance produced from the model's structured output + (typed as ``Any`` because the concrete class is the response schema + passed into :meth:`GenAIClient.invoke`). + usage: + Token usage counters. Fakes may return a zero-filled + :class:`GenAIUsage`. + model_name: + Echo of the model that served the request. Written to + ``ix_result.meta_data['model_name']`` by + :class:`~ix.pipeline.genai_step.GenAIStep`. + """ + + parsed: Any + usage: GenAIUsage + model_name: str + + +@runtime_checkable +class GenAIClient(Protocol): + """Async LLM backend with structured-output support. + + Implementations accept an already-assembled ``request_kwargs`` dict + (messages, model, format, etc.) and a Pydantic class describing the + expected structured-output schema, and return a + :class:`GenAIInvocationResult`. + """ + + async def invoke( + self, + request_kwargs: dict[str, Any], + response_schema: type[BaseModel], + ) -> GenAIInvocationResult: + """Run the LLM; parse the response into ``response_schema``; return it.""" + ... + + +__all__ = ["GenAIClient", "GenAIInvocationResult", "GenAIUsage"] diff --git a/src/ix/genai/fake.py b/src/ix/genai/fake.py new file mode 100644 index 0000000..544195e --- /dev/null +++ b/src/ix/genai/fake.py @@ -0,0 +1,61 @@ +"""FakeGenAIClient — returns a canned :class:`GenAIInvocationResult`. + +Used by every pipeline unit test to avoid booting Ollama. The +``raise_on_call`` hook lets error-path tests exercise ``IX_002_000``-style +code paths without needing a real network error. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + +from ix.genai.client import GenAIInvocationResult, GenAIUsage + + +class FakeGenAIClient: + """Satisfies :class:`~ix.genai.client.GenAIClient` structurally. + + Parameters + ---------- + parsed: + The pre-built model instance returned as ``result.parsed``. + usage: + Token usage counters. Defaults to a zero-filled + :class:`GenAIUsage`. + model_name: + Echoed on the result. Defaults to ``"fake"``. + raise_on_call: + If set, :meth:`invoke` raises this exception instead of returning. + """ + + def __init__( + self, + parsed: Any, + *, + usage: GenAIUsage | None = None, + model_name: str = "fake", + raise_on_call: BaseException | None = None, + ) -> None: + self._parsed = parsed + self._usage = usage if usage is not None else GenAIUsage() + self._model_name = model_name + self._raise_on_call = raise_on_call + + async def invoke( + self, + request_kwargs: dict[str, Any], + response_schema: type[BaseModel], + ) -> GenAIInvocationResult: + """Return the canned result or raise the configured error.""" + if self._raise_on_call is not None: + raise self._raise_on_call + return GenAIInvocationResult( + parsed=self._parsed, + usage=self._usage, + model_name=self._model_name, + ) + + +__all__ = ["FakeGenAIClient"] diff --git a/src/ix/ocr/__init__.py b/src/ix/ocr/__init__.py new file mode 100644 index 0000000..089a48a --- /dev/null +++ b/src/ix/ocr/__init__.py @@ -0,0 +1,13 @@ +"""OCR subsystem: protocol + fake client. + +Real engines (Surya, Azure DI, …) plug in behind :class:`OCRClient`. The +MVP ships only :class:`FakeOCRClient` from this package; the real Surya +client lands in Chunk 4. +""" + +from __future__ import annotations + +from ix.ocr.client import OCRClient +from ix.ocr.fake import FakeOCRClient + +__all__ = ["FakeOCRClient", "OCRClient"] diff --git a/src/ix/ocr/client.py b/src/ix/ocr/client.py new file mode 100644 index 0000000..ca84185 --- /dev/null +++ b/src/ix/ocr/client.py @@ -0,0 +1,32 @@ +"""OCRClient Protocol (spec §6.2). + +Structural typing: any object with an async ``ocr(pages) -> OCRResult`` +method satisfies the Protocol. :class:`~ix.pipeline.ocr_step.OCRStep` +depends on the Protocol, not a concrete class, so swapping engines +(``FakeOCRClient`` in tests, ``SuryaOCRClient`` in prod) stays a wiring +change at the app factory. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from ix.contracts import OCRResult, Page + + +@runtime_checkable +class OCRClient(Protocol): + """Async OCR backend. + + Implementations receive the flat page list the pipeline built in + :class:`~ix.pipeline.setup_step.SetupStep` and return an + :class:`~ix.contracts.OCRResult` with one :class:`~ix.contracts.Page` + per input page (in the same order). + """ + + async def ocr(self, pages: list[Page]) -> OCRResult: + """Run OCR over the input pages; return the structured result.""" + ... + + +__all__ = ["OCRClient"] diff --git a/src/ix/ocr/fake.py b/src/ix/ocr/fake.py new file mode 100644 index 0000000..ca811d8 --- /dev/null +++ b/src/ix/ocr/fake.py @@ -0,0 +1,40 @@ +"""FakeOCRClient — returns a canned :class:`OCRResult` for hermetic tests. + +Used by every pipeline unit test to avoid booting Surya / CUDA. The +``raise_on_call`` hook lets error-path tests exercise ``IX_002_000``-style +code paths without needing to forge network errors. +""" + +from __future__ import annotations + +from ix.contracts import OCRResult, Page + + +class FakeOCRClient: + """Satisfies :class:`~ix.ocr.client.OCRClient` structurally. + + Parameters + ---------- + canned: + The :class:`OCRResult` to return from every :meth:`ocr` call. + raise_on_call: + If set, :meth:`ocr` raises this exception instead of returning. + """ + + def __init__( + self, + canned: OCRResult, + *, + raise_on_call: BaseException | None = None, + ) -> None: + self._canned = canned + self._raise_on_call = raise_on_call + + async def ocr(self, pages: list[Page]) -> OCRResult: + """Return the canned result or raise the configured error.""" + if self._raise_on_call is not None: + raise self._raise_on_call + return self._canned + + +__all__ = ["FakeOCRClient"] diff --git a/tests/unit/test_genai_fake.py b/tests/unit/test_genai_fake.py new file mode 100644 index 0000000..48ff747 --- /dev/null +++ b/tests/unit/test_genai_fake.py @@ -0,0 +1,55 @@ +"""Tests for GenAIClient Protocol + FakeGenAIClient (spec §6.3).""" + +from __future__ import annotations + +import pytest +from pydantic import BaseModel + +from ix.genai import ( + FakeGenAIClient, + GenAIClient, + GenAIInvocationResult, + GenAIUsage, +) + + +class _Schema(BaseModel): + foo: str + bar: int + + +class TestProtocolConformance: + def test_fake_is_runtime_checkable_as_protocol(self) -> None: + client = FakeGenAIClient(parsed=_Schema(foo="x", bar=1)) + assert isinstance(client, GenAIClient) + + +class TestReturnsCannedResult: + async def test_defaults_populate_usage_and_model(self) -> None: + parsed = _Schema(foo="bank", bar=2) + client = FakeGenAIClient(parsed=parsed) + result = await client.invoke(request_kwargs={"model": "x"}, response_schema=_Schema) + assert isinstance(result, GenAIInvocationResult) + assert result.parsed is parsed + assert isinstance(result.usage, GenAIUsage) + assert result.usage.prompt_tokens == 0 + assert result.usage.completion_tokens == 0 + assert result.model_name == "fake" + + async def test_explicit_usage_and_model_passed_through(self) -> None: + parsed = _Schema(foo="k", bar=3) + usage = GenAIUsage(prompt_tokens=10, completion_tokens=20) + client = FakeGenAIClient(parsed=parsed, usage=usage, model_name="mock:0.1") + result = await client.invoke(request_kwargs={}, response_schema=_Schema) + assert result.usage is usage + assert result.model_name == "mock:0.1" + + +class TestRaiseOnCallHook: + async def test_raise_on_call_propagates(self) -> None: + err = RuntimeError("ollama is down") + client = FakeGenAIClient( + parsed=_Schema(foo="x", bar=1), raise_on_call=err + ) + with pytest.raises(RuntimeError, match="ollama is down"): + await client.invoke(request_kwargs={}, response_schema=_Schema) diff --git a/tests/unit/test_ocr_fake.py b/tests/unit/test_ocr_fake.py new file mode 100644 index 0000000..d4d66cd --- /dev/null +++ b/tests/unit/test_ocr_fake.py @@ -0,0 +1,57 @@ +"""Tests for OCRClient Protocol + FakeOCRClient (spec §6.2).""" + +from __future__ import annotations + +import pytest + +from ix.contracts import Line, OCRDetails, OCRResult, Page +from ix.ocr import FakeOCRClient, OCRClient + + +def _canned() -> OCRResult: + return OCRResult( + result=OCRDetails( + text="hello world", + pages=[ + Page( + page_no=1, + width=100.0, + height=200.0, + lines=[Line(text="hello world", bounding_box=[0, 0, 10, 0, 10, 5, 0, 5])], + ) + ], + ), + meta_data={"engine": "fake"}, + ) + + +class TestProtocolConformance: + def test_fake_is_runtime_checkable_as_protocol(self) -> None: + client = FakeOCRClient(canned=_canned()) + assert isinstance(client, OCRClient) + + +class TestReturnsCannedResult: + async def test_returns_exact_canned_result(self) -> None: + canned = _canned() + client = FakeOCRClient(canned=canned) + result = await client.ocr(pages=[]) + assert result is canned + assert result.result.text == "hello world" + assert result.meta_data == {"engine": "fake"} + + async def test_pages_argument_is_accepted_but_ignored(self) -> None: + canned = _canned() + client = FakeOCRClient(canned=canned) + result = await client.ocr( + pages=[Page(page_no=5, width=1.0, height=1.0, lines=[])] + ) + assert result is canned + + +class TestRaiseOnCallHook: + async def test_raise_on_call_propagates(self) -> None: + err = RuntimeError("surya is down") + client = FakeOCRClient(canned=_canned(), raise_on_call=err) + with pytest.raises(RuntimeError, match="surya is down"): + await client.ocr(pages=[])