feat(clients): OCRClient + GenAIClient protocols + fakes #10
8 changed files with 348 additions and 0 deletions
18
src/ix/genai/__init__.py
Normal file
18
src/ix/genai/__init__.py
Normal 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
72
src/ix/genai/client.py
Normal 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
61
src/ix/genai/fake.py
Normal 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
13
src/ix/ocr/__init__.py
Normal 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
32
src/ix/ocr/client.py
Normal 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
40
src/ix/ocr/fake.py
Normal 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"]
|
||||
55
tests/unit/test_genai_fake.py
Normal file
55
tests/unit/test_genai_fake.py
Normal 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)
|
||||
57
tests/unit/test_ocr_fake.py
Normal file
57
tests/unit/test_ocr_fake.py
Normal 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=[])
|
||||
Loading…
Reference in a new issue