feat(clients): OCRClient + GenAIClient protocols + fakes #10

Merged
goldstein merged 1 commit from feat/client-protocols into main 2026-04-18 09:08:38 +00:00
8 changed files with 348 additions and 0 deletions

18
src/ix/genai/__init__.py Normal file
View file

@ -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",
]

72
src/ix/genai/client.py Normal file
View file

@ -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"]

61
src/ix/genai/fake.py Normal file
View file

@ -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"]

13
src/ix/ocr/__init__.py Normal file
View file

@ -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"]

32
src/ix/ocr/client.py Normal file
View file

@ -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"]

40
src/ix/ocr/fake.py Normal file
View file

@ -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"]

View file

@ -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)

View file

@ -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=[])