Compare commits
2 commits
1344b9ddb4
...
2709fb8d6b
| Author | SHA1 | Date | |
|---|---|---|---|
| 2709fb8d6b | |||
| 118a9abd09 |
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