From 8f7d8d92594a3f303ae7e69717c8086872b41f1d Mon Sep 17 00:00:00 2001 From: danyalxahid-askui Date: Wed, 13 Aug 2025 01:30:52 +0200 Subject: [PATCH 01/10] feat(get): support PDF processing and refactor image handling - Updated the `get` method to accept both images and PDFs as sources. - Renamed the `image` parameter to `source` for clarity. - Added support for PDF processing in self hosted gemini models. - Enhanced documentation to reflect the new capabilities and usage examples. - Introduced a new `PdfSource` class for handling PDF inputs. - Updated tests to cover new PDF functionality and ensure compatibility with existing image handling. --- README.md | 18 +++-- src/askui/agent_base.py | 56 +++++++++++---- src/askui/models/anthropic/messages_api.py | 9 ++- src/askui/models/askui/get_model.py | 19 ++++-- src/askui/models/askui/google_genai_api.py | 20 ++++-- src/askui/models/askui/inference_api.py | 9 ++- src/askui/models/model_router.py | 6 +- src/askui/models/models.py | 20 +++--- src/askui/models/openrouter/model.py | 9 ++- .../models/shared/agent_message_param.py | 12 ++++ src/askui/models/shared/facade.py | 6 +- src/askui/models/ui_tars_ep/ui_tars_api.py | 4 +- src/askui/utils/image_utils.py | 64 ++++++++++++++++++ tests/e2e/agent/test_get.py | 63 ++++++++++++----- .../models/openrouter/test_openrouter.py | 8 +-- tests/integration/test_custom_models.py | 24 +++++-- tests/test_data/dummy.pdf | Bin 0 -> 67840 bytes 17 files changed, 263 insertions(+), 84 deletions(-) create mode 100644 tests/test_data/dummy.pdf diff --git a/README.md b/README.md index c698bb79..299a6ba8 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,7 @@ class MyGetAndLocateModel(GetModel, LocateModel): def get( self, query: str, - image: ImageSource, + source: Source, response_schema: Type[ResponseSchema] | None, model_choice: str, ) -> ResponseSchema | str: @@ -639,9 +639,9 @@ else: agent.click("Login") ``` -#### Using custom images +#### Using custom images and PDFs -Instead of taking a screenshot, you can analyze specific images: +Instead of taking a screenshot, you can analyze specific images or PDFs: ```python from PIL import Image @@ -650,10 +650,13 @@ from askui import VisionAgent # From PIL Image with VisionAgent() as agent: image = Image.open("screenshot.png") - result = agent.get("What's in this image?", image) + result = agent.get("What's in this image?", source=image) # From file path - result = agent.get("What's in this image?", "screenshot.png") + result = agent.get("What's in this image?", source="screenshot.png") + + # From PDF + result = agent.get("What is this PDF about?", source="document.pdf") ``` #### Using response schemas @@ -695,7 +698,7 @@ with VisionAgent() as agent: response = agent.get( "What is the current url shown in the url bar?", response_schema=UrlResponse, - image="screenshot.png", + source="screenshot.png", ) # Dump whole model @@ -711,7 +714,7 @@ with VisionAgent() as agent: is_login_page = agent.get( "Is this a login page?", response_schema=bool, - image=Image.open("screenshot.png"), + source=Image.open("screenshot.png"), ) print(is_login_page) @@ -750,6 +753,7 @@ with VisionAgent() as agent: **⚠️ Limitations:** - The support for response schemas varies among models. Currently, the `askui` model provides best support for response schemas as we try different models under the hood with your schema to see which one works best. +- PDF processing is only supported for Gemini models hosted on AskUI and for PDFs up to 20MB. ## What is AskUI Vision Agent? diff --git a/src/askui/agent_base.py b/src/askui/agent_base.py index a98a4679..9ced506f 100644 --- a/src/askui/agent_base.py +++ b/src/askui/agent_base.py @@ -1,6 +1,7 @@ import time import types from abc import ABC +from pathlib import Path from typing import Annotated, Optional, Type, overload from dotenv import load_dotenv @@ -15,7 +16,7 @@ from askui.models.shared.tools import Tool from askui.tools.agent_os import AgentOs from askui.tools.android.agent_os import AndroidAgentOs -from askui.utils.image_utils import ImageSource, Img +from askui.utils.image_utils import ImageSource, Img, Pdf, PdfSource, Source from .logger import configure_logging, logger from .models import ModelComposition @@ -189,7 +190,7 @@ def get( query: Annotated[str, Field(min_length=1)], response_schema: None = None, model: str | None = None, - image: Optional[Img] = None, + source: Optional[Img | Pdf] = None, ) -> str: ... @overload def get( @@ -197,38 +198,45 @@ def get( query: Annotated[str, Field(min_length=1)], response_schema: Type[ResponseSchema], model: str | None = None, - image: Optional[Img] = None, + source: Optional[Img | Pdf] = None, ) -> ResponseSchema: ... - @telemetry.record_call(exclude={"query", "image", "response_schema"}) + @telemetry.record_call(exclude={"query", "source", "response_schema"}) @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def get( self, query: Annotated[str, Field(min_length=1)], response_schema: Type[ResponseSchema] | None = None, model: str | None = None, - image: Optional[Img] = None, + source: Optional[Img | Pdf] = None, ) -> ResponseSchema | str: """ - Retrieves information from an image (defaults to a screenshot of the current - screen) based on the provided `query`. + Retrieves information from an image or PDF based on the provided `query`. + + If no `source` is provided, a screenshot of the current screen is taken. Args: query (str): The query describing what information to retrieve. - image (Img | None, optional): The image to extract information from. - Defaults to a screenshot of the current screen. Can be a path to - an image file, a PIL Image object or a data URL. + source (Img | Pdf | None, optional): The source to extract information from. + Can be a path to a PDF file, a path to an image file, a PIL Image + object or a data URL. Defaults to a screenshot of the current screen. response_schema (Type[ResponseSchema] | None, optional): A Pydantic model class that defines the response schema. If not provided, returns a string. model (str | None, optional): The composition or name of the model(s) to be used for retrieving information from the screen or image using the `query`. Note: `response_schema` is not supported by all models. + PDF processing is only supported for Gemini models hosted on AskUI. Returns: ResponseSchema | str: The extracted information, `str` if no `response_schema` is provided. + Raises: + NotImplementedError: If PDF processing is not supported for the selected + model. + ValueError: If the `source` is not a valid PDF or image. + Example: ```python from askui import ResponseSchemaBase, VisionAgent @@ -253,7 +261,7 @@ class LinkedListNode(ResponseSchemaBase): response = agent.get( "What is the current url shown in the url bar?", response_schema=UrlResponse, - image="screenshot.png", + source="screenshot.png", ) # Dump whole model print(response.model_dump_json(indent=2)) @@ -268,7 +276,7 @@ class LinkedListNode(ResponseSchemaBase): is_login_page = agent.get( "Is this a login page?", response_schema=bool, - image=Image.open("screenshot.png"), + source=Image.open("screenshot.png"), ) print(is_login_page) @@ -302,13 +310,31 @@ class LinkedListNode(ResponseSchemaBase): while current: print(current.value) current = current.next + + # Get text from PDF + text = agent.get( + "Extract all text from the PDF", + source="document.pdf", + ) + print(text) ``` """ logger.debug("VisionAgent received instruction to get '%s'", query) - _image = ImageSource(self._agent_os.screenshot() if image is None else image) - self._reporter.add_message("User", f'get: "{query}"', image=_image.root) + _source: Source | None = None + if source is None: + _source = ImageSource(self._agent_os.screenshot()) + elif isinstance(source, (str, Path)) and Path(source).suffix.lower() == ".pdf": + _source = PdfSource(source) + else: + _source = ImageSource(source) + + self._reporter.add_message( + "User", + f'get: "{query}"', + image=_source.root if isinstance(_source, ImageSource) else None, + ) response = self._model_router.get( - image=_image, + source=_source, query=query, response_schema=response_schema, model_choice=model or self._model_choice["get"], diff --git a/src/askui/models/anthropic/messages_api.py b/src/askui/models/anthropic/messages_api.py index bfb8704a..a4c9af7d 100644 --- a/src/askui/models/anthropic/messages_api.py +++ b/src/askui/models/anthropic/messages_api.py @@ -44,6 +44,8 @@ from askui.utils.dict_utils import IdentityDefaultDict from askui.utils.image_utils import ( ImageSource, + PdfSource, + Source, image_to_base64, scale_coordinates, scale_image_to_fit, @@ -238,16 +240,19 @@ def locate( def get( self, query: str, - image: ImageSource, + source: Source, response_schema: Type[ResponseSchema] | None, model_choice: str, ) -> ResponseSchema | str: + if isinstance(source, PdfSource): + err_msg = "PDF processing is not supported by this model" + raise NotImplementedError(err_msg) try: if response_schema is not None: error_msg = "Response schema is not yet supported for Anthropic" raise NotImplementedError(error_msg) return self._inference( - image=image, + image=source, prompt=query, system=SYSTEM_PROMPT_GET, model_choice=model_choice, diff --git a/src/askui/models/askui/get_model.py b/src/askui/models/askui/get_model.py index ef753caf..308c3b0d 100644 --- a/src/askui/models/askui/get_model.py +++ b/src/askui/models/askui/get_model.py @@ -7,9 +7,9 @@ from askui.models.askui.google_genai_api import AskUiGoogleGenAiApi from askui.models.askui.inference_api import AskUiInferenceApi from askui.models.exceptions import QueryNoResponseError, QueryUnexpectedResponseError -from askui.models.models import GetModel +from askui.models.models import GetModel, ModelName from askui.models.types.response_schemas import ResponseSchema -from askui.utils.image_utils import ImageSource +from askui.utils.image_utils import PdfSource, Source class AskUiGetModel(GetModel): @@ -39,15 +39,24 @@ def __init__( def get( self, query: str, - image: ImageSource, + source: Source, response_schema: Type[ResponseSchema] | None, model_choice: str, ) -> ResponseSchema | str: + if isinstance(source, PdfSource): + if model_choice not in [ + ModelName.ASKUI__GEMINI__2_5__FLASH, + ModelName.ASKUI__GEMINI__2_5__PRO, + ]: + err_msg = ( + f"PDF processing is not supported for the model '{model_choice}'" + ) + raise NotImplementedError(err_msg) try: logger.debug("Attempting to use Google GenAI API") return self._google_genai_api.get( query=query, - image=image, + source=source, response_schema=response_schema, model_choice=model_choice, ) @@ -66,7 +75,7 @@ def get( ) return self._inference_api.get( query=query, - image=image, + source=source, response_schema=response_schema, model_choice=model_choice, ) diff --git a/src/askui/models/askui/google_genai_api.py b/src/askui/models/askui/google_genai_api.py index 49b72d03..0089a845 100644 --- a/src/askui/models/askui/google_genai_api.py +++ b/src/askui/models/askui/google_genai_api.py @@ -22,7 +22,7 @@ from askui.models.shared.prompts import SYSTEM_PROMPT_GET from askui.models.types.response_schemas import ResponseSchema, to_response_schema from askui.utils.http_utils import parse_retry_after_header -from askui.utils.image_utils import ImageSource +from askui.utils.image_utils import ImageSource, PdfSource, Source ASKUI_MODEL_CHOICE_PREFIX = "askui/" ASKUI_MODEL_CHOICE_PREFIX_LEN = len(ASKUI_MODEL_CHOICE_PREFIX) @@ -112,7 +112,7 @@ def __init__(self, settings: AskUiInferenceApiSettings | None = None) -> None: def get( self, query: str, - image: ImageSource, + source: Source, response_schema: Type[ResponseSchema] | None, model_choice: str, ) -> ResponseSchema | str: @@ -120,12 +120,20 @@ def get( _response_schema = to_response_schema(response_schema) json_schema = _response_schema.model_json_schema() logger.debug(f"json_schema:\n{json_lib.dumps(json_schema)}") + part: genai_types.Part | None = None + if isinstance(source, ImageSource): + part = genai_types.Part.from_bytes( + data=source.to_bytes(), + mime_type="image/png", + ) + elif isinstance(source, PdfSource): + part = genai_types.Part.from_bytes( + data=source.root, + mime_type="application/pdf", + ) content = genai_types.Content( parts=[ - genai_types.Part.from_bytes( - data=image.to_bytes(), - mime_type="image/png", - ), + part, genai_types.Part.from_text(text=query), ], role="user", diff --git a/src/askui/models/askui/inference_api.py b/src/askui/models/askui/inference_api.py index d40c5150..8c6f4cf2 100644 --- a/src/askui/models/askui/inference_api.py +++ b/src/askui/models/askui/inference_api.py @@ -26,7 +26,7 @@ from askui.models.shared.settings import MessageSettings from askui.models.shared.tools import ToolCollection from askui.models.types.response_schemas import ResponseSchema -from askui.utils.image_utils import ImageSource +from askui.utils.image_utils import ImageSource, PdfSource, Source from ..types.response_schemas import to_response_schema @@ -196,12 +196,15 @@ def locate( def get( self, query: str, - image: ImageSource, + source: Source, response_schema: Type[ResponseSchema] | None, model_choice: str, ) -> ResponseSchema | str: + if isinstance(source, PdfSource): + err_msg = "PDF processing is not supported by this model" + raise NotImplementedError(err_msg) json: dict[str, Any] = { - "image": image.to_data_url(), + "image": source.to_data_url(), "prompt": query, } _response_schema = to_response_schema(response_schema) diff --git a/src/askui/models/model_router.py b/src/askui/models/model_router.py index 457ec10d..8c709f83 100644 --- a/src/askui/models/model_router.py +++ b/src/askui/models/model_router.py @@ -31,7 +31,7 @@ from askui.models.shared.tools import Tool from askui.models.types.response_schemas import ResponseSchema from askui.reporting import NULL_REPORTER, CompositeReporter, Reporter -from askui.utils.image_utils import ImageSource +from askui.utils.image_utils import ImageSource, Source from ..logger import logger from .askui.inference_api import AskUiInferenceApi @@ -199,13 +199,13 @@ def act( def get( self, query: str, - image: ImageSource, + source: Source, model_choice: str, response_schema: Type[ResponseSchema] | None = None, ) -> ResponseSchema | str: m = self._get_model(model_choice, "get") logger.debug(f'Routing "get" to model "{model_choice}"') - return m.get(query, image, response_schema, model_choice) + return m.get(query, source, response_schema, model_choice) def locate( self, diff --git a/src/askui/models/models.py b/src/askui/models/models.py index 5bd65ddc..0137224f 100644 --- a/src/askui/models/models.py +++ b/src/askui/models/models.py @@ -13,7 +13,7 @@ from askui.models.shared.settings import ActSettings from askui.models.shared.tools import Tool from askui.models.types.response_schemas import ResponseSchema -from askui.utils.image_utils import ImageSource +from askui.utils.image_utils import ImageSource, Source class ModelName(str, Enum): @@ -231,23 +231,22 @@ def act( class GetModel(abc.ABC): - """Abstract base class for models that can extract information from images. + """Abstract base class for models that can extract information from images and PDFs. Models implementing this interface can be used with the `get()` method of `VisionAgent` - to extract information from screenshots or other images. These models analyze visual - content and return structured or unstructured information based on queries. - + to extract information from screenshots, other images or PDFs. These models analyze + visual content and return structured or unstructured information based on queries. Example: ```python - from askui import GetModel, VisionAgent, ResponseSchema, ImageSource + from askui import GetModel, VisionAgent, ResponseSchema, Source from typing import Type class MyGetModel(GetModel): def get( self, query: str, - image: ImageSource, + source: Source, response_schema: Type[ResponseSchema] | None, model_choice: str, ) -> ResponseSchema | str: @@ -263,15 +262,14 @@ def get( def get( self, query: str, - image: ImageSource, + source: Source, response_schema: Type[ResponseSchema] | None, model_choice: str, ) -> ResponseSchema | str: - """Extract information from an image based on a query. - + """Extract information from a source based on a query. Args: query (str): A description of what information to extract - image (ImageSource): The image to analyze (screenshot or provided image) + source (Source): The source to analyze (screenshot, image or PDF) response_schema (Type[ResponseSchema] | None): Optional Pydantic model class defining the expected response structure model_choice (str): The name of the model being used (useful for models that diff --git a/src/askui/models/openrouter/model.py b/src/askui/models/openrouter/model.py index 3ece12f4..b85bed21 100644 --- a/src/askui/models/openrouter/model.py +++ b/src/askui/models/openrouter/model.py @@ -10,7 +10,7 @@ from askui.models.models import GetModel from askui.models.shared.prompts import SYSTEM_PROMPT_GET from askui.models.types.response_schemas import ResponseSchema, to_response_schema -from askui.utils.image_utils import ImageSource +from askui.utils.image_utils import PdfSource, Source from .settings import OpenRouterSettings @@ -169,12 +169,15 @@ def _predict( def get( self, query: str, - image: ImageSource, + source: Source, response_schema: Type[ResponseSchema] | None, model_choice: str, ) -> ResponseSchema | str: + if isinstance(source, PdfSource): + err_msg = "PDF processing is not supported by this model" + raise NotImplementedError(err_msg) response = self._predict( - image_url=image.to_data_url(), + image_url=source.to_data_url(), instruction=query, prompt=SYSTEM_PROMPT_GET, response_schema=response_schema, diff --git a/src/askui/models/shared/agent_message_param.py b/src/askui/models/shared/agent_message_param.py index 6265ab36..2684c165 100644 --- a/src/askui/models/shared/agent_message_param.py +++ b/src/askui/models/shared/agent_message_param.py @@ -57,6 +57,17 @@ class ImageBlockParam(BaseModel): cache_control: CacheControlEphemeralParam | None = None +class Base64PdfSourceParam(BaseModel): + data: str + media_type: Literal["application/pdf"] + type: Literal["base64"] = "base64" + + +class PdfBlockParam(BaseModel): + source: Base64PdfSourceParam + type: Literal["pdf"] = "pdf" + + class TextBlockParam(BaseModel): text: str type: Literal["text"] = "text" @@ -98,6 +109,7 @@ class BetaRedactedThinkingBlock(BaseModel): | ToolUseBlockParam | BetaThinkingBlock | BetaRedactedThinkingBlock + | PdfBlockParam ) StopReason = Literal[ diff --git a/src/askui/models/shared/facade.py b/src/askui/models/shared/facade.py index 9789f3b1..1fe2b929 100644 --- a/src/askui/models/shared/facade.py +++ b/src/askui/models/shared/facade.py @@ -9,7 +9,7 @@ from askui.models.shared.settings import ActSettings from askui.models.shared.tools import Tool from askui.models.types.response_schemas import ResponseSchema -from askui.utils.image_utils import ImageSource +from askui.utils.image_utils import ImageSource, Source class ModelFacade(ActModel, GetModel, LocateModel): @@ -44,11 +44,11 @@ def act( def get( self, query: str, - image: ImageSource, + source: Source, response_schema: Type[ResponseSchema] | None, model_choice: str, ) -> ResponseSchema | str: - return self._get_model.get(query, image, response_schema, model_choice) + return self._get_model.get(query, source, response_schema, model_choice) @override def locate( diff --git a/src/askui/models/ui_tars_ep/ui_tars_api.py b/src/askui/models/ui_tars_ep/ui_tars_api.py index 4c2c84ee..b5f021c2 100644 --- a/src/askui/models/ui_tars_ep/ui_tars_api.py +++ b/src/askui/models/ui_tars_ep/ui_tars_api.py @@ -176,7 +176,7 @@ def locate( def get( self, query: str, - image: ImageSource, + source: ImageSource, response_schema: Type[ResponseSchema] | None, model_choice: str, ) -> ResponseSchema | str: @@ -184,7 +184,7 @@ def get( error_msg = f'Response schema is not supported for model "{model_choice}"' raise NotImplementedError(error_msg) response = self._predict( - image_url=image.to_data_url(), + image_url=source.to_data_url(), instruction=query, prompt=PROMPT_QA, ) diff --git a/src/askui/utils/image_utils.py b/src/askui/utils/image_utils.py index bfefc4ac..87907206 100644 --- a/src/askui/utils/image_utils.py +++ b/src/askui/utils/image_utils.py @@ -14,6 +14,32 @@ # Regex to capture any kind of valid base64 data url (with optional media type and ;base64) # e.g., data:image/png;base64,... or data:;base64,... or data:,... or just ,... _DATA_URL_GENERIC_RE = re.compile(r"^(?:data:)?[^,]*?,(.*)$", re.DOTALL) +PDF_MAX_SIZE_BYTES = 20 * 1024 * 1024 + + +def load_pdf(source: Union[str, Path]) -> bytes: + """Load a PDF from a path and return its bytes. + + Args: + source (Union[str, Path]): The PDF source to load from. + + Returns: + bytes: The PDF content as bytes. + + Raises: + FileNotFoundError: If the file is not found. + ValueError: If the file is too large. + """ + filepath = Path(source) + if not filepath.is_file(): + err_msg = f"No such file or directory: '{source}'" + raise FileNotFoundError(err_msg) + + if filepath.stat().st_size > PDF_MAX_SIZE_BYTES: + err_msg = f"PDF file size exceeds the limit of {PDF_MAX_SIZE_BYTES} bytes." + raise ValueError(err_msg) + + return filepath.read_bytes() def load_image(source: Union[str, Path, Image.Image]) -> Image.Image: @@ -367,6 +393,13 @@ def scale_coordinates( - Data URL (e.g., `"data:image/png;base64,..."`) """ +Pdf = Union[str, Path] +"""Type of the input PDFs for `askui.VisionAgent.get()`, etc. + +Accepts: +- Relative or absolute file path (`str` or `pathlib.Path`) +""" + class ImageSource(RootModel): """A class that represents an image source and provides methods to convert it to different formats. @@ -421,6 +454,34 @@ def to_bytes(self) -> bytes: return img_byte_arr.getvalue() +class PdfSource(RootModel): + """A class that represents a PDF source and provides methods to convert it to different formats. + + The class can be initialized with: + - A file path (str or pathlib.Path) + + Attributes: + root (bytes): The underlying PDF bytes. + + Args: + root (Pdf): The PDF source to load from. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + root: bytes + + def __init__(self, root: Pdf, **kwargs: dict[str, Any]) -> None: + super().__init__(root=root, **kwargs) + + @field_validator("root", mode="before") + @classmethod + def validate_root(cls, v: Any) -> bytes: + return load_pdf(v) + + +Source = Union[ImageSource, PdfSource] + + __all__ = [ "load_image", "image_to_data_url", @@ -433,4 +494,7 @@ def to_bytes(self) -> bytes: "ScalingResults", "ImageSource", "Img", + "PdfSource", + "Pdf", + "Source", ] diff --git a/tests/e2e/agent/test_get.py b/tests/e2e/agent/test_get.py index 5dc1d43a..7b2868fa 100644 --- a/tests/e2e/agent/test_get.py +++ b/tests/e2e/agent/test_get.py @@ -43,12 +43,43 @@ def test_get( ) -> None: url = vision_agent.get( "What is the current url shown in the url bar?\nUrl: ", - image=github_login_screenshot, + source=github_login_screenshot, model=model, ) assert url in ["github.com/login", "https://github.com/login"] +def test_get_with_pdf_with_non_gemini_model_raises_not_implemented( + vision_agent: VisionAgent, +) -> None: + with pytest.raises(NotImplementedError): + vision_agent.get( + "What is in the PDF?", + source="tests/test_data/dummy.pdf", + model=ModelName.ANTHROPIC__CLAUDE__3_5__SONNET__20241022, + ) + + +@pytest.mark.parametrize( + "model", + [ + ModelName.ASKUI__GEMINI__2_5__FLASH, + ModelName.ASKUI__GEMINI__2_5__PRO, + ], +) +def test_get_with_pdf_with_gemini_model( + vision_agent: VisionAgent, + model: str, +) -> None: + response = vision_agent.get( + "What is in the PDF? explain in 1 sentence", + source="tests/test_data/dummy.pdf", + model=model, + ) + assert isinstance(response, str) + assert "is a test page " in response.lower() + + def test_get_with_model_composition_should_use_default_model( agent_toolbox_mock: AgentToolbox, askui_facade: ModelFacade, @@ -76,7 +107,7 @@ def test_get_with_model_composition_should_use_default_model( ) as vision_agent: url = vision_agent.get( "What is the current url shown in the url bar?", - image=github_login_screenshot, + source=github_login_screenshot, ) assert url in ["github.com/login", "https://github.com/login"] @@ -92,7 +123,7 @@ def test_get_with_response_schema_without_additional_properties_with_askui_model with pytest.raises(Exception): # noqa: B017 vision_agent.get( "What is the current url shown in the url bar?", - image=github_login_screenshot, + source=github_login_screenshot, response_schema=UrlResponseBaseModel, # type: ignore[type-var] model=ModelName.ASKUI, ) @@ -108,7 +139,7 @@ def test_get_with_response_schema_with_default_value( ) -> None: response = vision_agent.get( "What is the current url shown in the url bar?", - image=github_login_screenshot, + source=github_login_screenshot, response_schema=OptionalUrlResponse, model=ModelName.ASKUI, ) @@ -124,7 +155,7 @@ def test_get_with_response_schema( ) -> None: response = vision_agent.get( "What is the current url shown in the url bar?", - image=github_login_screenshot, + source=github_login_screenshot, response_schema=UrlResponse, model=model, ) @@ -139,7 +170,7 @@ def test_get_with_response_schema_with_anthropic_model_raises_not_implemented( with pytest.raises(NotImplementedError): vision_agent.get( "What is the current url shown in the url bar?", - image=github_login_screenshot, + source=github_login_screenshot, response_schema=UrlResponse, model=ModelName.CLAUDE__SONNET__4__20250514, ) @@ -153,7 +184,7 @@ def test_get_with_nested_and_inherited_response_schema( ) -> None: response = vision_agent.get( "What is the current browser context?", - image=github_login_screenshot, + source=github_login_screenshot, response_schema=BrowserContextResponse, model=model, ) @@ -177,7 +208,7 @@ def test_get_with_recursive_response_schema( response = vision_agent.get( "Can you extract all segments (domain, path etc.) from the url as a linked list, " "e.g. 'https://google.com/test' -> 'google.com->test->None'?", - image=github_login_screenshot, + source=github_login_screenshot, response_schema=LinkedListNode, model=model, ) @@ -200,7 +231,7 @@ def test_get_with_string_schema( ) -> None: response = vision_agent.get( "What is the current url shown in the url bar?", - image=github_login_screenshot, + source=github_login_screenshot, response_schema=str, model=model, ) @@ -215,7 +246,7 @@ def test_get_with_boolean_schema( ) -> None: response = vision_agent.get( "Is this a login page?", - image=github_login_screenshot, + source=github_login_screenshot, response_schema=bool, model=model, ) @@ -231,7 +262,7 @@ def test_get_with_integer_schema( ) -> None: response = vision_agent.get( "How many input fields are visible on this page?", - image=github_login_screenshot, + source=github_login_screenshot, response_schema=int, model=model, ) @@ -247,7 +278,7 @@ def test_get_with_float_schema( ) -> None: response = vision_agent.get( "Return a floating point number between 0 and 1 as a rating for how you well this page is designed (0 is the worst, 1 is the best)", - image=github_login_screenshot, + source=github_login_screenshot, response_schema=float, model=model, ) @@ -263,7 +294,7 @@ def test_get_returns_str_when_no_schema_specified( ) -> None: response = vision_agent.get( "What is the display showing?", - image=github_login_screenshot, + source=github_login_screenshot, model=model, ) assert isinstance(response, str) @@ -281,7 +312,7 @@ def test_get_with_basis_schema( ) -> None: response = vision_agent.get( "What is the display showing?", - image=github_login_screenshot, + source=github_login_screenshot, response_schema=Basis, model=model, ) @@ -305,7 +336,7 @@ def test_get_with_nested_root_model( ) -> None: response = vision_agent.get( "What is the display showing?", - image=github_login_screenshot, + source=github_login_screenshot, response_schema=BasisWithNestedRootModel, model=model, ) @@ -353,7 +384,7 @@ def test_get_with_deeply_nested_response_schema_with_model_that_does_not_support """ response = vision_agent.get( "Create a possible dom of the page that goes 4 levels deep", - image=github_login_screenshot, + source=github_login_screenshot, response_schema=PageDom, model=model, ) diff --git a/tests/integration/models/openrouter/test_openrouter.py b/tests/integration/models/openrouter/test_openrouter.py index 25615610..51866a5b 100644 --- a/tests/integration/models/openrouter/test_openrouter.py +++ b/tests/integration/models/openrouter/test_openrouter.py @@ -37,7 +37,7 @@ def test_basic_query_returns_string( result = openrouter_model.get( query="What is in the image?", - image=image_source_github_login_screenshot, + source=image_source_github_login_screenshot, response_schema=None, model_choice="test-model", ) @@ -64,7 +64,7 @@ def test_query_with_response_schema_returns_validated_object( result = openrouter_model.get( query="What is in the image?", - image=image_source_github_login_screenshot, + source=image_source_github_login_screenshot, response_schema=TestResponse, model_choice="test-model", ) @@ -87,7 +87,7 @@ def test_no_response_from_model( with pytest.raises(QueryNoResponseError): openrouter_model.get( query="What is in the image?", - image=image_source_github_login_screenshot, + source=image_source_github_login_screenshot, response_schema=None, model_choice="test-model", ) @@ -106,7 +106,7 @@ def test_malformed_json_from_model( with pytest.raises(ValueError): openrouter_model.get( query="What is in the image?", - image=image_source_github_login_screenshot, + source=image_source_github_login_screenshot, response_schema=TestResponse, model_choice="test-model", ) diff --git a/tests/integration/test_custom_models.py b/tests/integration/test_custom_models.py index c1991b49..3671878f 100644 --- a/tests/integration/test_custom_models.py +++ b/tests/integration/test_custom_models.py @@ -22,7 +22,7 @@ from askui.models.shared.settings import ActSettings from askui.models.shared.tools import Tool from askui.tools.toolbox import AgentToolbox -from askui.utils.image_utils import ImageSource +from askui.utils.image_utils import ImageSource, Source class SimpleActModel(ActModel): @@ -50,7 +50,7 @@ class SimpleGetModel(GetModel): def __init__(self, response: str | ResponseSchemaBase = "test response") -> None: self.queries: list[str] = [] - self.images: list[ImageSource] = [] + self.sources: list[Source] = [] self.schemas: list[Any] = [] self.model_choices: list[str] = [] self.response = response @@ -58,12 +58,12 @@ def __init__(self, response: str | ResponseSchemaBase = "test response") -> None def get( self, query: str, - image: ImageSource, + source: Source, response_schema: Optional[Type[ResponseSchema]], model_choice: str, ) -> Union[ResponseSchema, str]: self.queries.append(query) - self.images.append(image) + self.sources.append(source) self.schemas.append(response_schema) self.model_choices.append(model_choice) if ( @@ -163,6 +163,22 @@ def test_register_and_use_custom_get_model( assert get_model.queries == ["test query"] assert get_model.model_choices == ["custom-get"] + def test_register_and_use_custom_get_model_with_pdf( + self, + model_registry: ModelRegistry, + get_model: SimpleGetModel, + agent_toolbox_mock: AgentToolbox, + ) -> None: + """Test registering and using a custom get model with a PDF.""" + with VisionAgent(models=model_registry, tools=agent_toolbox_mock) as agent: + result = agent.get( + "test query", model="custom-get", source="tests/test_data/dummy.pdf" + ) + + assert result == "test response" + assert get_model.queries == ["test query"] + assert get_model.model_choices == ["custom-get"] + def test_register_and_use_custom_locate_model( self, model_registry: ModelRegistry, diff --git a/tests/test_data/dummy.pdf b/tests/test_data/dummy.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e0191a71e5b402dc89b848c61d903b7ea985926e GIT binary patch literal 67840 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≥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 literal 0 HcmV?d00001 From cc1740ba7b0cd42905bc74e38504670761a9f267 Mon Sep 17 00:00:00 2001 From: danyalxahid-askui Date: Wed, 13 Aug 2025 01:44:37 +0200 Subject: [PATCH 02/10] fix(models): improve error messages for unsupported PDF processing - Updated error messages in the `get` method across multiple models to specify the model name when PDF processing is not supported. - Changed the `source` parameter type in `UiTarsApiHandler.get` method from `ImageSource` to `Source` for keeping it consistent with other models e.g. openrouter etc --- src/askui/models/anthropic/messages_api.py | 2 +- src/askui/models/askui/inference_api.py | 2 +- src/askui/models/openrouter/model.py | 2 +- src/askui/models/ui_tars_ep/ui_tars_api.py | 7 +++++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/askui/models/anthropic/messages_api.py b/src/askui/models/anthropic/messages_api.py index a4c9af7d..5cf210fd 100644 --- a/src/askui/models/anthropic/messages_api.py +++ b/src/askui/models/anthropic/messages_api.py @@ -245,7 +245,7 @@ def get( model_choice: str, ) -> ResponseSchema | str: if isinstance(source, PdfSource): - err_msg = "PDF processing is not supported by this model" + err_msg = f"PDF processing is not supported for model {model_choice}" raise NotImplementedError(err_msg) try: if response_schema is not None: diff --git a/src/askui/models/askui/inference_api.py b/src/askui/models/askui/inference_api.py index 8c6f4cf2..8f370e16 100644 --- a/src/askui/models/askui/inference_api.py +++ b/src/askui/models/askui/inference_api.py @@ -201,7 +201,7 @@ def get( model_choice: str, ) -> ResponseSchema | str: if isinstance(source, PdfSource): - err_msg = "PDF processing is not supported by this model" + err_msg = f"PDF processing is not supported for model {model_choice}" raise NotImplementedError(err_msg) json: dict[str, Any] = { "image": source.to_data_url(), diff --git a/src/askui/models/openrouter/model.py b/src/askui/models/openrouter/model.py index b85bed21..4010257a 100644 --- a/src/askui/models/openrouter/model.py +++ b/src/askui/models/openrouter/model.py @@ -174,7 +174,7 @@ def get( model_choice: str, ) -> ResponseSchema | str: if isinstance(source, PdfSource): - err_msg = "PDF processing is not supported by this model" + err_msg = f"PDF processing is not supported for model {model_choice}" raise NotImplementedError(err_msg) response = self._predict( image_url=source.to_data_url(), diff --git a/src/askui/models/ui_tars_ep/ui_tars_api.py b/src/askui/models/ui_tars_ep/ui_tars_api.py index b5f021c2..cb759c0f 100644 --- a/src/askui/models/ui_tars_ep/ui_tars_api.py +++ b/src/askui/models/ui_tars_ep/ui_tars_api.py @@ -18,7 +18,7 @@ from askui.models.shared.tools import Tool from askui.models.types.response_schemas import ResponseSchema from askui.reporting import Reporter -from askui.utils.image_utils import ImageSource, image_to_base64 +from askui.utils.image_utils import ImageSource, PdfSource, Source, image_to_base64 from .parser import UITarsEPMessage from .prompts import PROMPT, PROMPT_QA @@ -176,10 +176,13 @@ def locate( def get( self, query: str, - source: ImageSource, + source: Source, response_schema: Type[ResponseSchema] | None, model_choice: str, ) -> ResponseSchema | str: + if isinstance(source, PdfSource): + err_msg = f"PDF processing is not supported for model {model_choice}" + raise NotImplementedError(err_msg) if response_schema is not None: error_msg = f'Response schema is not supported for model "{model_choice}"' raise NotImplementedError(error_msg) From ed0127b7166ebac2ec2d486d71ed3de460008b02 Mon Sep 17 00:00:00 2001 From: danyalxahid-askui Date: Wed, 13 Aug 2025 02:13:23 +0200 Subject: [PATCH 03/10] feat(tests): add PDF fixtures and enhance test coverage for PDF handling - Introduced new fixtures for PDF paths in `conftest.py` to streamline test setup. - Updated tests in `test_get.py` to utilize the new PDF fixtures for improved clarity and maintainability. - Added integration tests in `test_custom_models.py` to validate custom model registration with PDF sources. - Implemented unit tests in `test_image_utils.py` for the `load_pdf` function, covering various scenarios including loading from path and handling errors for non-existent or large files. --- tests/conftest.py | 12 +++++++++ tests/e2e/agent/test_get.py | 10 ++++---- tests/{test_data => fixtures/pdf}/dummy.pdf | Bin tests/integration/test_custom_models.py | 4 ++- tests/unit/utils/test_image_utils.py | 27 ++++++++++++++++++++ 5 files changed, 47 insertions(+), 6 deletions(-) rename tests/{test_data => fixtures/pdf}/dummy.pdf (100%) diff --git a/tests/conftest.py b/tests/conftest.py index c8dab188..72b7cba4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,6 +28,18 @@ def path_fixtures_screenshots(path_fixtures: pathlib.Path) -> pathlib.Path: return path_fixtures / "screenshots" +@pytest.fixture +def path_fixtures_pdf(path_fixtures: pathlib.Path) -> pathlib.Path: + """Fixture providing the path to the pdf directory.""" + return path_fixtures / "pdf" + + +@pytest.fixture +def path_fixtures_dummy_pdf(path_fixtures_pdf: pathlib.Path) -> pathlib.Path: + """Fixture providing the path to the dummy pdf.""" + return path_fixtures_pdf / "dummy.pdf" + + @pytest.fixture def github_login_screenshot(path_fixtures_screenshots: pathlib.Path) -> Image.Image: """Fixture providing the GitHub login screenshot.""" diff --git a/tests/e2e/agent/test_get.py b/tests/e2e/agent/test_get.py index 7b2868fa..369962af 100644 --- a/tests/e2e/agent/test_get.py +++ b/tests/e2e/agent/test_get.py @@ -1,3 +1,4 @@ +import pathlib from typing import Literal import pytest @@ -50,12 +51,12 @@ def test_get( def test_get_with_pdf_with_non_gemini_model_raises_not_implemented( - vision_agent: VisionAgent, + vision_agent: VisionAgent, path_fixtures_dummy_pdf: pathlib.Path ) -> None: with pytest.raises(NotImplementedError): vision_agent.get( "What is in the PDF?", - source="tests/test_data/dummy.pdf", + source=path_fixtures_dummy_pdf, model=ModelName.ANTHROPIC__CLAUDE__3_5__SONNET__20241022, ) @@ -68,12 +69,11 @@ def test_get_with_pdf_with_non_gemini_model_raises_not_implemented( ], ) def test_get_with_pdf_with_gemini_model( - vision_agent: VisionAgent, - model: str, + vision_agent: VisionAgent, model: str, path_fixtures_dummy_pdf: pathlib.Path ) -> None: response = vision_agent.get( "What is in the PDF? explain in 1 sentence", - source="tests/test_data/dummy.pdf", + source=path_fixtures_dummy_pdf, model=model, ) assert isinstance(response, str) diff --git a/tests/test_data/dummy.pdf b/tests/fixtures/pdf/dummy.pdf similarity index 100% rename from tests/test_data/dummy.pdf rename to tests/fixtures/pdf/dummy.pdf diff --git a/tests/integration/test_custom_models.py b/tests/integration/test_custom_models.py index 3671878f..25305922 100644 --- a/tests/integration/test_custom_models.py +++ b/tests/integration/test_custom_models.py @@ -1,5 +1,6 @@ """Integration tests for custom model registration and selection.""" +import pathlib from typing import Any, Optional, Type, Union import pytest @@ -168,11 +169,12 @@ def test_register_and_use_custom_get_model_with_pdf( model_registry: ModelRegistry, get_model: SimpleGetModel, agent_toolbox_mock: AgentToolbox, + path_fixtures_dummy_pdf: pathlib.Path, ) -> None: """Test registering and using a custom get model with a PDF.""" with VisionAgent(models=model_registry, tools=agent_toolbox_mock) as agent: result = agent.get( - "test query", model="custom-get", source="tests/test_data/dummy.pdf" + "test query", model="custom-get", source=path_fixtures_dummy_pdf ) assert result == "test response" diff --git a/tests/unit/utils/test_image_utils.py b/tests/unit/utils/test_image_utils.py index f739655a..9be2ebc3 100644 --- a/tests/unit/utils/test_image_utils.py +++ b/tests/unit/utils/test_image_utils.py @@ -3,6 +3,7 @@ import pytest from PIL import Image +from pytest_mock import MockerFixture from askui.utils.image_utils import ( ImageSource, @@ -13,11 +14,37 @@ image_to_base64, image_to_data_url, load_image, + load_pdf, scale_coordinates, scale_image_to_fit, ) +class TestLoadPdf: + def test_load_pdf_from_path(self, path_fixtures_dummy_pdf: pathlib.Path) -> None: + # Test loading from Path + loaded = load_pdf(path_fixtures_dummy_pdf) + assert isinstance(loaded, bytes) + assert len(loaded) > 0 + + # Test loading from str path + loaded = load_pdf(str(path_fixtures_dummy_pdf)) + assert isinstance(loaded, bytes) + assert len(loaded) > 0 + + def test_load_pdf_nonexistent_file(self) -> None: + with pytest.raises(FileNotFoundError): + load_pdf("nonexistent_file.pdf") + + def test_load_pdf_too_large( + self, mocker: MockerFixture, path_fixtures_dummy_pdf: pathlib.Path + ) -> None: + mocker.patch("pathlib.Path.stat", return_value=mocker.Mock(st_size=99999999)) + mocker.patch("pathlib.Path.is_file", return_value=True) + with pytest.raises(ValueError): + load_pdf(path_fixtures_dummy_pdf) + + class TestLoadImage: def test_load_image_from_pil( self, path_fixtures_github_com__icon: pathlib.Path From 21cd154be4f8a88e41648169883a124949df78d0 Mon Sep 17 00:00:00 2001 From: danyalxahid-askui Date: Thu, 14 Aug 2025 12:59:52 +0200 Subject: [PATCH 04/10] feat(file-utils): introduce file handling utilities for image and PDF sources - Added a new `file_utils.py` module to handle loading of image and PDF sources. - Implemented the `load_source` function to determine the appropriate source type based on the input. - Created a `PdfSource` class in `pdf_utils.py` for managing PDF inputs. - Updated various model files to utilize the new `Source` type and refactored image handling. - Enhanced error messages for unsupported PDF processing in multiple models. - Added tests for the `load_pdf` function to ensure proper functionality and error handling. --- pdm.lock | 12 +++- pyproject.toml | 1 + src/askui/agent_base.py | 23 +++++--- src/askui/models/anthropic/messages_api.py | 6 +- src/askui/models/askui/get_model.py | 3 +- src/askui/models/askui/google_genai_api.py | 61 ++++++++++++++++---- src/askui/models/askui/inference_api.py | 6 +- src/askui/models/model_router.py | 3 +- src/askui/models/models.py | 3 +- src/askui/models/openrouter/model.py | 5 +- src/askui/models/shared/facade.py | 3 +- src/askui/models/ui_tars_ep/ui_tars_api.py | 6 +- src/askui/utils/file_utils.py | 33 +++++++++++ src/askui/utils/image_utils.py | 64 --------------------- src/askui/utils/pdf_utils.py | 65 ++++++++++++++++++++++ tests/e2e/agent/test_get.py | 26 +++++++++ tests/integration/test_custom_models.py | 3 +- tests/integration/utils/test_pdf_utils.py | 22 ++++++++ tests/unit/utils/test_image_utils.py | 27 --------- 19 files changed, 245 insertions(+), 127 deletions(-) create mode 100644 src/askui/utils/file_utils.py create mode 100644 src/askui/utils/pdf_utils.py create mode 100644 tests/integration/utils/test_pdf_utils.py diff --git a/pdm.lock b/pdm.lock index 333efa4e..5952bf23 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "all", "android", "chat", "dev", "mcp", "pynput", "test", "web"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:5033ba9a7c1da164c790105efce8b762b5ccc39bf8b872bca8451916baab0a8e" +content_hash = "sha256:3fe75d92bfe97e6b257a5591a7d6ad8355209fe259fc580cd8262c982f3485e3" [[metadata.targets]] requires_python = ">=3.10" @@ -679,6 +679,16 @@ files = [ {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"}, ] +[[package]] +name = "filetype" +version = "1.2.0" +summary = "Infer file type and MIME type of any file/buffer. No external dependencies." +groups = ["default"] +files = [ + {file = "filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25"}, + {file = "filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb"}, +] + [[package]] name = "fsspec" version = "2025.3.2" diff --git a/pyproject.toml b/pyproject.toml index 7f3efe83..823e559a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "jsonref>=1.1.0", "protobuf>=6.31.1", "google-genai>=1.20.0", + "filetype>=1.2.0", ] requires-python = ">=3.10" readme = "README.md" diff --git a/src/askui/agent_base.py b/src/askui/agent_base.py index 9ced506f..4c6f7eb0 100644 --- a/src/askui/agent_base.py +++ b/src/askui/agent_base.py @@ -16,7 +16,9 @@ from askui.models.shared.tools import Tool from askui.tools.agent_os import AgentOs from askui.tools.android.agent_os import AndroidAgentOs -from askui.utils.image_utils import ImageSource, Img, Pdf, PdfSource, Source +from askui.utils.file_utils import load_source +from askui.utils.image_utils import ImageSource, Img +from askui.utils.pdf_utils import Pdf from .logger import configure_logging, logger from .models import ModelComposition @@ -320,17 +322,20 @@ class LinkedListNode(ResponseSchemaBase): ``` """ logger.debug("VisionAgent received instruction to get '%s'", query) - _source: Source | None = None - if source is None: - _source = ImageSource(self._agent_os.screenshot()) - elif isinstance(source, (str, Path)) and Path(source).suffix.lower() == ".pdf": - _source = PdfSource(source) - else: - _source = ImageSource(source) + _source = ( + ImageSource(self._agent_os.screenshot()) + if source is None + else load_source(source) + ) + + # Prepare message content with file path if available + user_message_content = f'get: "{query}"' + ( + f" from '{source}'" if isinstance(source, (str, Path)) else "" + ) self._reporter.add_message( "User", - f'get: "{query}"', + user_message_content, image=_source.root if isinstance(_source, ImageSource) else None, ) response = self._model_router.get( diff --git a/src/askui/models/anthropic/messages_api.py b/src/askui/models/anthropic/messages_api.py index 5cf210fd..e98c3d14 100644 --- a/src/askui/models/anthropic/messages_api.py +++ b/src/askui/models/anthropic/messages_api.py @@ -42,14 +42,14 @@ from askui.models.shared.tools import ToolCollection from askui.models.types.response_schemas import ResponseSchema from askui.utils.dict_utils import IdentityDefaultDict +from askui.utils.file_utils import Source from askui.utils.image_utils import ( ImageSource, - PdfSource, - Source, image_to_base64, scale_coordinates, scale_image_to_fit, ) +from askui.utils.pdf_utils import PdfSource from .utils import extract_click_coordinates @@ -245,7 +245,7 @@ def get( model_choice: str, ) -> ResponseSchema | str: if isinstance(source, PdfSource): - err_msg = f"PDF processing is not supported for model {model_choice}" + err_msg = f"PDF processing is not supported for the model {model_choice}" raise NotImplementedError(err_msg) try: if response_schema is not None: diff --git a/src/askui/models/askui/get_model.py b/src/askui/models/askui/get_model.py index 308c3b0d..f3b941c3 100644 --- a/src/askui/models/askui/get_model.py +++ b/src/askui/models/askui/get_model.py @@ -9,7 +9,8 @@ from askui.models.exceptions import QueryNoResponseError, QueryUnexpectedResponseError from askui.models.models import GetModel, ModelName from askui.models.types.response_schemas import ResponseSchema -from askui.utils.image_utils import PdfSource, Source +from askui.utils.file_utils import Source +from askui.utils.pdf_utils import PdfSource class AskUiGetModel(GetModel): diff --git a/src/askui/models/askui/google_genai_api.py b/src/askui/models/askui/google_genai_api.py index 0089a845..95f8c864 100644 --- a/src/askui/models/askui/google_genai_api.py +++ b/src/askui/models/askui/google_genai_api.py @@ -21,11 +21,14 @@ from askui.models.models import GetModel, ModelName from askui.models.shared.prompts import SYSTEM_PROMPT_GET from askui.models.types.response_schemas import ResponseSchema, to_response_schema +from askui.utils.file_utils import Source from askui.utils.http_utils import parse_retry_after_header -from askui.utils.image_utils import ImageSource, PdfSource, Source +from askui.utils.image_utils import ImageSource +from askui.utils.pdf_utils import PdfSource ASKUI_MODEL_CHOICE_PREFIX = "askui/" ASKUI_MODEL_CHOICE_PREFIX_LEN = len(ASKUI_MODEL_CHOICE_PREFIX) +MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024 class _wait_for_retry_after_header(wait_base): @@ -120,17 +123,7 @@ def get( _response_schema = to_response_schema(response_schema) json_schema = _response_schema.model_json_schema() logger.debug(f"json_schema:\n{json_lib.dumps(json_schema)}") - part: genai_types.Part | None = None - if isinstance(source, ImageSource): - part = genai_types.Part.from_bytes( - data=source.to_bytes(), - mime_type="image/png", - ) - elif isinstance(source, PdfSource): - part = genai_types.Part.from_bytes( - data=source.root, - mime_type="application/pdf", - ) + part = self._create_genai_part_from_source(source) content = genai_types.Content( parts=[ part, @@ -166,3 +159,47 @@ def get( "Recursive response schemas are not supported by AskUiGoogleGenAiApi" ) raise NotImplementedError(error_message) from e + + def _create_genai_part_from_source(self, source: Source) -> genai_types.Part: + """Create a genai Part from a Source object. + + Only ImageSource and PdfSource are currently supported. + + Args: + source (Source): The source object to convert. + + Returns: + genai_types.Part: The genai Part object. + + Raises: + NotImplementedError: If source type is not ImageSource or PdfSource. + ValueError: If the source data exceeds the size limit. + """ + if isinstance(source, ImageSource): + data = source.to_bytes() + if len(data) > MAX_FILE_SIZE_BYTES: + _err_msg = ( + f"Image file size exceeds the limit of {MAX_FILE_SIZE_BYTES} bytes." + ) + raise ValueError(_err_msg) + return genai_types.Part.from_bytes( + data=data, + mime_type="image/png", + ) + if isinstance(source, PdfSource): + data = source.root + if len(data) > MAX_FILE_SIZE_BYTES: + _err_msg = ( + f"PDF file size exceeds the limit of {MAX_FILE_SIZE_BYTES} bytes." + ) + raise ValueError(_err_msg) + return genai_types.Part.from_bytes( + data=data, + mime_type="application/pdf", + ) + # Explicit error for unsupported source types (for future extensibility) + err_msg = ( # type: ignore[unreachable] + f"Unsupported source type: {type(source).__name__}. " + "Only ImageSource and PdfSource are supported." + ) + raise NotImplementedError(err_msg) diff --git a/src/askui/models/askui/inference_api.py b/src/askui/models/askui/inference_api.py index 8f370e16..6b22efc3 100644 --- a/src/askui/models/askui/inference_api.py +++ b/src/askui/models/askui/inference_api.py @@ -26,7 +26,9 @@ from askui.models.shared.settings import MessageSettings from askui.models.shared.tools import ToolCollection from askui.models.types.response_schemas import ResponseSchema -from askui.utils.image_utils import ImageSource, PdfSource, Source +from askui.utils.file_utils import Source +from askui.utils.image_utils import ImageSource +from askui.utils.pdf_utils import PdfSource from ..types.response_schemas import to_response_schema @@ -201,7 +203,7 @@ def get( model_choice: str, ) -> ResponseSchema | str: if isinstance(source, PdfSource): - err_msg = f"PDF processing is not supported for model {model_choice}" + err_msg = f"PDF processing is not supported for the model {model_choice}" raise NotImplementedError(err_msg) json: dict[str, Any] = { "image": source.to_data_url(), diff --git a/src/askui/models/model_router.py b/src/askui/models/model_router.py index 8c709f83..27efb719 100644 --- a/src/askui/models/model_router.py +++ b/src/askui/models/model_router.py @@ -31,7 +31,8 @@ from askui.models.shared.tools import Tool from askui.models.types.response_schemas import ResponseSchema from askui.reporting import NULL_REPORTER, CompositeReporter, Reporter -from askui.utils.image_utils import ImageSource, Source +from askui.utils.file_utils import Source +from askui.utils.image_utils import ImageSource from ..logger import logger from .askui.inference_api import AskUiInferenceApi diff --git a/src/askui/models/models.py b/src/askui/models/models.py index 0137224f..d5612228 100644 --- a/src/askui/models/models.py +++ b/src/askui/models/models.py @@ -13,7 +13,8 @@ from askui.models.shared.settings import ActSettings from askui.models.shared.tools import Tool from askui.models.types.response_schemas import ResponseSchema -from askui.utils.image_utils import ImageSource, Source +from askui.utils.file_utils import Source +from askui.utils.image_utils import ImageSource class ModelName(str, Enum): diff --git a/src/askui/models/openrouter/model.py b/src/askui/models/openrouter/model.py index 4010257a..fc379589 100644 --- a/src/askui/models/openrouter/model.py +++ b/src/askui/models/openrouter/model.py @@ -10,7 +10,8 @@ from askui.models.models import GetModel from askui.models.shared.prompts import SYSTEM_PROMPT_GET from askui.models.types.response_schemas import ResponseSchema, to_response_schema -from askui.utils.image_utils import PdfSource, Source +from askui.utils.file_utils import Source +from askui.utils.pdf_utils import PdfSource from .settings import OpenRouterSettings @@ -174,7 +175,7 @@ def get( model_choice: str, ) -> ResponseSchema | str: if isinstance(source, PdfSource): - err_msg = f"PDF processing is not supported for model {model_choice}" + err_msg = f"PDF processing is not supported for the model {model_choice}" raise NotImplementedError(err_msg) response = self._predict( image_url=source.to_data_url(), diff --git a/src/askui/models/shared/facade.py b/src/askui/models/shared/facade.py index 1fe2b929..32e62dca 100644 --- a/src/askui/models/shared/facade.py +++ b/src/askui/models/shared/facade.py @@ -9,7 +9,8 @@ from askui.models.shared.settings import ActSettings from askui.models.shared.tools import Tool from askui.models.types.response_schemas import ResponseSchema -from askui.utils.image_utils import ImageSource, Source +from askui.utils.file_utils import Source +from askui.utils.image_utils import ImageSource class ModelFacade(ActModel, GetModel, LocateModel): diff --git a/src/askui/models/ui_tars_ep/ui_tars_api.py b/src/askui/models/ui_tars_ep/ui_tars_api.py index cb759c0f..fedf4dde 100644 --- a/src/askui/models/ui_tars_ep/ui_tars_api.py +++ b/src/askui/models/ui_tars_ep/ui_tars_api.py @@ -18,7 +18,9 @@ from askui.models.shared.tools import Tool from askui.models.types.response_schemas import ResponseSchema from askui.reporting import Reporter -from askui.utils.image_utils import ImageSource, PdfSource, Source, image_to_base64 +from askui.utils.file_utils import Source +from askui.utils.image_utils import ImageSource, image_to_base64 +from askui.utils.pdf_utils import PdfSource from .parser import UITarsEPMessage from .prompts import PROMPT, PROMPT_QA @@ -181,7 +183,7 @@ def get( model_choice: str, ) -> ResponseSchema | str: if isinstance(source, PdfSource): - err_msg = f"PDF processing is not supported for model {model_choice}" + err_msg = f"PDF processing is not supported for the model {model_choice}" raise NotImplementedError(err_msg) if response_schema is not None: error_msg = f'Response schema is not supported for model "{model_choice}"' diff --git a/src/askui/utils/file_utils.py b/src/askui/utils/file_utils.py new file mode 100644 index 00000000..0c10ae4d --- /dev/null +++ b/src/askui/utils/file_utils.py @@ -0,0 +1,33 @@ +from pathlib import Path +from typing import Union + +from filetype import guess # type: ignore[import-untyped] +from PIL import Image + +from askui.utils.image_utils import ImageSource +from askui.utils.pdf_utils import PdfSource + +Source = Union[ImageSource, PdfSource] + + +def load_source(source: Union[str, Path, Image.Image]) -> Source: + """Load a source and return appropriate Source object based on file type.""" + + if isinstance(source, Image.Image): + return ImageSource(source) + + filepath = Path(source) + if not filepath.is_file(): + msg = f"No such file or directory: '{source}'" + raise FileNotFoundError(msg) + + kind = guess(str(filepath)) + if kind and kind.mime == "application/pdf": + return PdfSource(source) + if kind and kind.mime.startswith("image/"): + return ImageSource(source) + msg = f"Unsupported file type: {filepath.suffix}" + raise ValueError(msg) + + +__all__ = ["load_source", "Source"] diff --git a/src/askui/utils/image_utils.py b/src/askui/utils/image_utils.py index 87907206..bfefc4ac 100644 --- a/src/askui/utils/image_utils.py +++ b/src/askui/utils/image_utils.py @@ -14,32 +14,6 @@ # Regex to capture any kind of valid base64 data url (with optional media type and ;base64) # e.g., data:image/png;base64,... or data:;base64,... or data:,... or just ,... _DATA_URL_GENERIC_RE = re.compile(r"^(?:data:)?[^,]*?,(.*)$", re.DOTALL) -PDF_MAX_SIZE_BYTES = 20 * 1024 * 1024 - - -def load_pdf(source: Union[str, Path]) -> bytes: - """Load a PDF from a path and return its bytes. - - Args: - source (Union[str, Path]): The PDF source to load from. - - Returns: - bytes: The PDF content as bytes. - - Raises: - FileNotFoundError: If the file is not found. - ValueError: If the file is too large. - """ - filepath = Path(source) - if not filepath.is_file(): - err_msg = f"No such file or directory: '{source}'" - raise FileNotFoundError(err_msg) - - if filepath.stat().st_size > PDF_MAX_SIZE_BYTES: - err_msg = f"PDF file size exceeds the limit of {PDF_MAX_SIZE_BYTES} bytes." - raise ValueError(err_msg) - - return filepath.read_bytes() def load_image(source: Union[str, Path, Image.Image]) -> Image.Image: @@ -393,13 +367,6 @@ def scale_coordinates( - Data URL (e.g., `"data:image/png;base64,..."`) """ -Pdf = Union[str, Path] -"""Type of the input PDFs for `askui.VisionAgent.get()`, etc. - -Accepts: -- Relative or absolute file path (`str` or `pathlib.Path`) -""" - class ImageSource(RootModel): """A class that represents an image source and provides methods to convert it to different formats. @@ -454,34 +421,6 @@ def to_bytes(self) -> bytes: return img_byte_arr.getvalue() -class PdfSource(RootModel): - """A class that represents a PDF source and provides methods to convert it to different formats. - - The class can be initialized with: - - A file path (str or pathlib.Path) - - Attributes: - root (bytes): The underlying PDF bytes. - - Args: - root (Pdf): The PDF source to load from. - """ - - model_config = ConfigDict(arbitrary_types_allowed=True) - root: bytes - - def __init__(self, root: Pdf, **kwargs: dict[str, Any]) -> None: - super().__init__(root=root, **kwargs) - - @field_validator("root", mode="before") - @classmethod - def validate_root(cls, v: Any) -> bytes: - return load_pdf(v) - - -Source = Union[ImageSource, PdfSource] - - __all__ = [ "load_image", "image_to_data_url", @@ -494,7 +433,4 @@ def validate_root(cls, v: Any) -> bytes: "ScalingResults", "ImageSource", "Img", - "PdfSource", - "Pdf", - "Source", ] diff --git a/src/askui/utils/pdf_utils.py b/src/askui/utils/pdf_utils.py new file mode 100644 index 00000000..dc817d38 --- /dev/null +++ b/src/askui/utils/pdf_utils.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import Any, Union + +from pydantic import ConfigDict, RootModel, field_validator + +Pdf = Union[str, Path] +"""Type of the input PDFs for `askui.VisionAgent.get()`, etc. + +Accepts: +- Relative or absolute file path (`str` or `pathlib.Path`) +""" + + +def load_pdf(source: Union[str, Path]) -> bytes: + """Load a PDF from a path and return its bytes. + + Args: + source (Union[str, Path]): The PDF source to load from. + + Returns: + bytes: The PDF content as bytes. + + Raises: + FileNotFoundError: If the file is not found. + ValueError: If the file is too large. + """ + filepath = Path(source) + if not filepath.is_file(): + err_msg = f"No such file or directory: '{source}'" + raise FileNotFoundError(err_msg) + + return filepath.read_bytes() + + +class PdfSource(RootModel): + """A class that represents a PDF source. + It provides methods to convert it to different formats. + + The class can be initialized with: + - A file path (str or pathlib.Path) + + Attributes: + root (bytes): The underlying PDF bytes. + + Args: + root (Pdf): The PDF source to load from. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + root: bytes + + def __init__(self, root: Pdf, **kwargs: dict[str, Any]) -> None: + super().__init__(root=root, **kwargs) + + @field_validator("root", mode="before") + @classmethod + def validate_root(cls, v: Any) -> bytes: + return load_pdf(v) + + +__all__ = [ + "PdfSource", + "Pdf", + "load_pdf", +] diff --git a/tests/e2e/agent/test_get.py b/tests/e2e/agent/test_get.py index 369962af..5b6fdfd9 100644 --- a/tests/e2e/agent/test_get.py +++ b/tests/e2e/agent/test_get.py @@ -4,6 +4,7 @@ import pytest from PIL import Image as PILImage from pydantic import BaseModel, RootModel +from pytest_mock import MockerFixture from askui import ResponseSchemaBase, VisionAgent from askui.models import ModelName @@ -80,6 +81,31 @@ def test_get_with_pdf_with_gemini_model( assert "is a test page " in response.lower() +@pytest.mark.parametrize( + "model", + [ + ModelName.ASKUI__GEMINI__2_5__FLASH, + ModelName.ASKUI__GEMINI__2_5__PRO, + ], +) +def test_get_with_pdf_too_large( + vision_agent: VisionAgent, + model: str, + path_fixtures_dummy_pdf: pathlib.Path, + mocker: MockerFixture, +) -> None: + mocker.patch( + "askui.models.askui.google_genai_api.MAX_FILE_SIZE_BYTES", + 1, + ) + with pytest.raises(ValueError, match="PDF file size exceeds the limit"): + vision_agent.get( + "What is in the PDF?", + source=path_fixtures_dummy_pdf, + model=model, + ) + + def test_get_with_model_composition_should_use_default_model( agent_toolbox_mock: AgentToolbox, askui_facade: ModelFacade, diff --git a/tests/integration/test_custom_models.py b/tests/integration/test_custom_models.py index 25305922..8bec2371 100644 --- a/tests/integration/test_custom_models.py +++ b/tests/integration/test_custom_models.py @@ -23,7 +23,8 @@ from askui.models.shared.settings import ActSettings from askui.models.shared.tools import Tool from askui.tools.toolbox import AgentToolbox -from askui.utils.image_utils import ImageSource, Source +from askui.utils.file_utils import Source +from askui.utils.image_utils import ImageSource class SimpleActModel(ActModel): diff --git a/tests/integration/utils/test_pdf_utils.py b/tests/integration/utils/test_pdf_utils.py new file mode 100644 index 00000000..df6af54c --- /dev/null +++ b/tests/integration/utils/test_pdf_utils.py @@ -0,0 +1,22 @@ +import pathlib + +import pytest + +from askui.utils.pdf_utils import load_pdf + + +class TestLoadPdf: + def test_load_pdf_from_path(self, path_fixtures_dummy_pdf: pathlib.Path) -> None: + # Test loading from Path + loaded = load_pdf(path_fixtures_dummy_pdf) + assert isinstance(loaded, bytes) + assert len(loaded) > 0 + + # Test loading from str path + loaded = load_pdf(str(path_fixtures_dummy_pdf)) + assert isinstance(loaded, bytes) + assert len(loaded) > 0 + + def test_load_pdf_nonexistent_file(self) -> None: + with pytest.raises(FileNotFoundError): + load_pdf("nonexistent_file.pdf") diff --git a/tests/unit/utils/test_image_utils.py b/tests/unit/utils/test_image_utils.py index 9be2ebc3..f739655a 100644 --- a/tests/unit/utils/test_image_utils.py +++ b/tests/unit/utils/test_image_utils.py @@ -3,7 +3,6 @@ import pytest from PIL import Image -from pytest_mock import MockerFixture from askui.utils.image_utils import ( ImageSource, @@ -14,37 +13,11 @@ image_to_base64, image_to_data_url, load_image, - load_pdf, scale_coordinates, scale_image_to_fit, ) -class TestLoadPdf: - def test_load_pdf_from_path(self, path_fixtures_dummy_pdf: pathlib.Path) -> None: - # Test loading from Path - loaded = load_pdf(path_fixtures_dummy_pdf) - assert isinstance(loaded, bytes) - assert len(loaded) > 0 - - # Test loading from str path - loaded = load_pdf(str(path_fixtures_dummy_pdf)) - assert isinstance(loaded, bytes) - assert len(loaded) > 0 - - def test_load_pdf_nonexistent_file(self) -> None: - with pytest.raises(FileNotFoundError): - load_pdf("nonexistent_file.pdf") - - def test_load_pdf_too_large( - self, mocker: MockerFixture, path_fixtures_dummy_pdf: pathlib.Path - ) -> None: - mocker.patch("pathlib.Path.stat", return_value=mocker.Mock(st_size=99999999)) - mocker.patch("pathlib.Path.is_file", return_value=True) - with pytest.raises(ValueError): - load_pdf(path_fixtures_dummy_pdf) - - class TestLoadImage: def test_load_image_from_pil( self, path_fixtures_github_com__icon: pathlib.Path From 011038c1eed5a983f134acaa8043e807d4c83cc4 Mon Sep 17 00:00:00 2001 From: danyalxahid-askui Date: Thu, 14 Aug 2025 14:14:04 +0200 Subject: [PATCH 05/10] test(tests): enhance PDF handling tests and fix assertions - Updated the assertion in `test_get_with_pdf_with_gemini_model` to check for a more specific response. - Added a new test `test_get_with_pdf_too_large_with_default_model` to verify that a `NotImplementedError` is raised when attempting to process a large PDF with the default model, which does not support PDF processing. --- tests/e2e/agent/test_get.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/e2e/agent/test_get.py b/tests/e2e/agent/test_get.py index 5b6fdfd9..50a0da4b 100644 --- a/tests/e2e/agent/test_get.py +++ b/tests/e2e/agent/test_get.py @@ -78,7 +78,7 @@ def test_get_with_pdf_with_gemini_model( model=model, ) assert isinstance(response, str) - assert "is a test page " in response.lower() + assert "is a test " in response.lower() @pytest.mark.parametrize( @@ -106,6 +106,27 @@ def test_get_with_pdf_too_large( ) +def test_get_with_pdf_too_large_with_default_model( + vision_agent: VisionAgent, + path_fixtures_dummy_pdf: pathlib.Path, + mocker: MockerFixture, +) -> None: + mocker.patch( + "askui.models.askui.google_genai_api.MAX_FILE_SIZE_BYTES", + 1, + ) + + # This should raise a ValueError because the default model is Gemini and it falls + # back to inference askui which does not support pdfs + with pytest.raises( + NotImplementedError, match="PDF processing is not supported for the model" + ): + vision_agent.get( + "What is in the PDF?", + source=path_fixtures_dummy_pdf, + ) + + def test_get_with_model_composition_should_use_default_model( agent_toolbox_mock: AgentToolbox, askui_facade: ModelFacade, From 723742a2b1712aec4991eabcbb8a97f9e4b13f81 Mon Sep 17 00:00:00 2001 From: danyalxahid-askui Date: Thu, 14 Aug 2025 14:19:16 +0200 Subject: [PATCH 06/10] refactor(models): remove unused PDF-related classes from agent_message_param.py - Deleted `Base64PdfSourceParam` and `PdfBlockParam` classes as they are no longer needed. - Updated `ContentBlockParam` to exclude `PdfBlockParam`, streamlining the model definitions. --- src/askui/models/shared/agent_message_param.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/askui/models/shared/agent_message_param.py b/src/askui/models/shared/agent_message_param.py index 2684c165..6265ab36 100644 --- a/src/askui/models/shared/agent_message_param.py +++ b/src/askui/models/shared/agent_message_param.py @@ -57,17 +57,6 @@ class ImageBlockParam(BaseModel): cache_control: CacheControlEphemeralParam | None = None -class Base64PdfSourceParam(BaseModel): - data: str - media_type: Literal["application/pdf"] - type: Literal["base64"] = "base64" - - -class PdfBlockParam(BaseModel): - source: Base64PdfSourceParam - type: Literal["pdf"] = "pdf" - - class TextBlockParam(BaseModel): text: str type: Literal["text"] = "text" @@ -109,7 +98,6 @@ class BetaRedactedThinkingBlock(BaseModel): | ToolUseBlockParam | BetaThinkingBlock | BetaRedactedThinkingBlock - | PdfBlockParam ) StopReason = Literal[ From 4e0dc2da0dae36efe560d4aeb8465b81b4e9aaa6 Mon Sep 17 00:00:00 2001 From: danyalxahid-askui Date: Fri, 15 Aug 2025 00:15:42 +0200 Subject: [PATCH 07/10] refactor(file-utils): enhance file handling utilities for images and PDFs - Breaking change: Required `datatype` in data url to detect supported file formats - Introduced a new `io_utils.py` module for determining MIME types and reading bytes from various sources. - Refactored `load_source` in `file_utils.py` to utilize the new `source_file_type` function for improved file type detection. - Updated `ImageSource` and `PdfSource` classes to leverage the `read_bytes` function for loading data. - Removed the deprecated `load_image` and `load_pdf` functions, streamlining the codebase. - Enhanced tests for `PdfSource` and `ImageSource` to validate loading from paths and data URLs, ensuring robust error handling. --- src/askui/utils/file_utils.py | 48 ++++++++----- src/askui/utils/image_utils.py | 59 ++++----------- src/askui/utils/io_utils.py | 77 ++++++++++++++++++++ src/askui/utils/pdf_utils.py | 26 +------ tests/integration/utils/test_pdf_utils.py | 37 +++++++--- tests/unit/utils/test_image_utils.py | 87 ++++++++--------------- 6 files changed, 183 insertions(+), 151 deletions(-) create mode 100644 src/askui/utils/io_utils.py diff --git a/src/askui/utils/file_utils.py b/src/askui/utils/file_utils.py index 0c10ae4d..304b9c9b 100644 --- a/src/askui/utils/file_utils.py +++ b/src/askui/utils/file_utils.py @@ -1,33 +1,49 @@ from pathlib import Path from typing import Union -from filetype import guess # type: ignore[import-untyped] -from PIL import Image +from PIL import Image as PILImage from askui.utils.image_utils import ImageSource +from askui.utils.io_utils import source_file_type from askui.utils.pdf_utils import PdfSource +# to avoid circular imports from image_utils and pdf_utils on read_bytes Source = Union[ImageSource, PdfSource] +ALLOWED_IMAGE_TYPES = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +] -def load_source(source: Union[str, Path, Image.Image]) -> Source: - """Load a source and return appropriate Source object based on file type.""" +PDF_TYPE = "application/pdf" - if isinstance(source, Image.Image): - return ImageSource(source) +ALLOWED_MIMETYPES = [PDF_TYPE] + ALLOWED_IMAGE_TYPES - filepath = Path(source) - if not filepath.is_file(): - msg = f"No such file or directory: '{source}'" - raise FileNotFoundError(msg) - kind = guess(str(filepath)) - if kind and kind.mime == "application/pdf": - return PdfSource(source) - if kind and kind.mime.startswith("image/"): +def load_source(source: Union[str, Path, PILImage.Image]) -> Source: + """Load a source and return it as an ImageSource or PdfSource. + + Args: + source (Union[str, Path]): The source to load. + + Returns: + Source: The loaded source as an ImageSource or PdfSource. + + Raises: + ValueError: If the source is not a valid image or PDF file. + """ + if isinstance(source, PILImage.Image): + return ImageSource(source) + + file_type = source_file_type(source) + if file_type in ALLOWED_IMAGE_TYPES: return ImageSource(source) - msg = f"Unsupported file type: {filepath.suffix}" + if file_type == PDF_TYPE: + return PdfSource(source) + msg = f"Unsupported file type: {file_type}" raise ValueError(msg) -__all__ = ["load_source", "Source"] +__all__ = ["Source", "load_source"] diff --git a/src/askui/utils/image_utils.py b/src/askui/utils/image_utils.py index bfefc4ac..8dc83b7d 100644 --- a/src/askui/utils/image_utils.py +++ b/src/askui/utils/image_utils.py @@ -11,52 +11,20 @@ from PIL import Image as PILImage from pydantic import ConfigDict, RootModel, field_validator +from askui.utils.io_utils import read_bytes + # Regex to capture any kind of valid base64 data url (with optional media type and ;base64) # e.g., data:image/png;base64,... or data:;base64,... or data:,... or just ,... _DATA_URL_GENERIC_RE = re.compile(r"^(?:data:)?[^,]*?,(.*)$", re.DOTALL) -def load_image(source: Union[str, Path, Image.Image]) -> Image.Image: - """Load and validate an image from a PIL Image, a path, or any form of base64 data URL. - - Args: - source (Union[str, Path, Image.Image]): The image source to load from. - Can be a PIL Image, file path (`str` or `pathlib.Path`), or data URL. - - Returns: - Image.Image: A valid PIL Image object. - - Raises: - ValueError: If the input is not a valid or recognizable image. - """ - if isinstance(source, Image.Image): - return source - - if isinstance(source, Path) or (not source.startswith(("data:", ","))): - try: - return Image.open(source) - except (OSError, FileNotFoundError, UnidentifiedImageError) as e: - error_msg = f"Could not open image from file path: {source}" - raise ValueError(error_msg) from e - - else: - match = _DATA_URL_GENERIC_RE.match(source) - if match: - try: - image_data = base64.b64decode(match.group(1)) - return Image.open(io.BytesIO(image_data)) - except (binascii.Error, UnidentifiedImageError): - try: - return Image.open(source) - except (FileNotFoundError, UnidentifiedImageError) as e: - error_msg = ( - f"Could not decode or identify image from input:" - f"{source[:100]}{'...' if len(source) > 100 else ''}" - ) - raise ValueError(error_msg) from e - - error_msg = f"Unsupported image input type: {type(source)}" - raise ValueError(error_msg) +def _bytes_to_image(image_bytes: bytes) -> Image.Image: + """Convert bytes to a PIL Image.""" + try: + return Image.open(io.BytesIO(image_bytes)) + except (FileNotFoundError, UnidentifiedImageError) as e: + error_msg = "Could not identify image from bytes" + raise ValueError(error_msg) from e def image_to_data_url(image: PILImage.Image) -> str: @@ -391,8 +359,12 @@ def __init__(self, root: Img, **kwargs: dict[str, Any]) -> None: @field_validator("root", mode="before") @classmethod - def validate_root(cls, v: Any) -> PILImage.Image: - return load_image(v) + def validate_root(cls, v: Any) -> Image.Image: + if isinstance(v, Image.Image): + return v + + image_bytes = read_bytes(v) + return _bytes_to_image(image_bytes) def to_data_url(self) -> str: """Convert the image to a data URL. @@ -422,7 +394,6 @@ def to_bytes(self) -> bytes: __all__ = [ - "load_image", "image_to_data_url", "data_url_to_image", "draw_point_on_image", diff --git a/src/askui/utils/io_utils.py b/src/askui/utils/io_utils.py new file mode 100644 index 00000000..b6a918de --- /dev/null +++ b/src/askui/utils/io_utils.py @@ -0,0 +1,77 @@ +import base64 +import binascii +import re +from pathlib import Path +from typing import Union + +from filetype import guess # type: ignore[import-untyped] + +_DATA_URL_WITH_MIMETYPE_RE = re.compile(r"^data:([^;,]+)[^,]*?,(.*)$", re.DOTALL) + + +def source_file_type(source: Union[str, Path]) -> str: + """Determines the MIME type of a source. + + The source can be a file path or a data URL. + + Args: + source (Union[str , Path]): The source to determine the type of. + Can be a file path (`str` or `pathlib.Path`) or a data URL. + + Returns: + str: The MIME type of the source, or "unknown" if it cannot be determined. + """ + + # when source is a data url + if isinstance(source, str) and source.startswith("data:"): + match = _DATA_URL_WITH_MIMETYPE_RE.match(source) + if match and match.group(1): + return match.group(1) + else: + kind = guess(str(source)) + if kind is not None and kind.mime is not None: + return str(kind.mime) + + return "unknown" + + +def read_bytes(source: Union[str, Path]) -> bytes: + """Read the bytes of a source. + + The source can be a file path or a data URL. + + Args: + source (Union[str, Path]): The source to read the bytes from. + + Returns: + bytes: The content of the source as bytes. + """ + # when source is a file path and not a data url + if isinstance(source, Path) or ( + isinstance(source, str) and not source.startswith(("data:", ",")) + ): + filepath = Path(source) + if not filepath.is_file(): + err_msg = f"No such file or directory: '{source}'" + raise ValueError(err_msg) + + return filepath.read_bytes() + + # when source is a data url + if isinstance(source, str) and source.startswith(("data:", ",")): + match = _DATA_URL_WITH_MIMETYPE_RE.match(source) + if match: + try: + return base64.b64decode(match.group(2)) + except binascii.Error as e: + error_msg = ( + "Could not decode base64 data from input: " + f"{source[:100]}{'...' if len(source) > 100 else ''}" + ) + raise ValueError(error_msg) from e + + msg = f"Unsupported source type: {type(source)}" + raise ValueError(msg) + + +__all__ = ["read_bytes"] diff --git a/src/askui/utils/pdf_utils.py b/src/askui/utils/pdf_utils.py index dc817d38..98c59ab9 100644 --- a/src/askui/utils/pdf_utils.py +++ b/src/askui/utils/pdf_utils.py @@ -3,6 +3,8 @@ from pydantic import ConfigDict, RootModel, field_validator +from askui.utils.io_utils import read_bytes + Pdf = Union[str, Path] """Type of the input PDFs for `askui.VisionAgent.get()`, etc. @@ -11,27 +13,6 @@ """ -def load_pdf(source: Union[str, Path]) -> bytes: - """Load a PDF from a path and return its bytes. - - Args: - source (Union[str, Path]): The PDF source to load from. - - Returns: - bytes: The PDF content as bytes. - - Raises: - FileNotFoundError: If the file is not found. - ValueError: If the file is too large. - """ - filepath = Path(source) - if not filepath.is_file(): - err_msg = f"No such file or directory: '{source}'" - raise FileNotFoundError(err_msg) - - return filepath.read_bytes() - - class PdfSource(RootModel): """A class that represents a PDF source. It provides methods to convert it to different formats. @@ -55,11 +36,10 @@ def __init__(self, root: Pdf, **kwargs: dict[str, Any]) -> None: @field_validator("root", mode="before") @classmethod def validate_root(cls, v: Any) -> bytes: - return load_pdf(v) + return read_bytes(v) __all__ = [ "PdfSource", "Pdf", - "load_pdf", ] diff --git a/tests/integration/utils/test_pdf_utils.py b/tests/integration/utils/test_pdf_utils.py index df6af54c..2947a126 100644 --- a/tests/integration/utils/test_pdf_utils.py +++ b/tests/integration/utils/test_pdf_utils.py @@ -1,22 +1,41 @@ +import base64 import pathlib import pytest -from askui.utils.pdf_utils import load_pdf +from askui.utils.file_utils import PdfSource class TestLoadPdf: def test_load_pdf_from_path(self, path_fixtures_dummy_pdf: pathlib.Path) -> None: # Test loading from Path - loaded = load_pdf(path_fixtures_dummy_pdf) - assert isinstance(loaded, bytes) - assert len(loaded) > 0 + loaded = PdfSource(path_fixtures_dummy_pdf) + assert isinstance(loaded.root, bytes) + assert len(loaded.root) > 0 # Test loading from str path - loaded = load_pdf(str(path_fixtures_dummy_pdf)) - assert isinstance(loaded, bytes) - assert len(loaded) > 0 + loaded = PdfSource(str(path_fixtures_dummy_pdf)) + assert isinstance(loaded.root, bytes) + assert len(loaded.root) > 0 def test_load_pdf_nonexistent_file(self) -> None: - with pytest.raises(FileNotFoundError): - load_pdf("nonexistent_file.pdf") + with pytest.raises(ValueError): + PdfSource("nonexistent_file.pdf") + + def test_pdf_source_from_data_url( + self, path_fixtures_dummy_pdf: pathlib.Path + ) -> None: + # Load test image and convert to base64 + with pathlib.Path.open(path_fixtures_dummy_pdf, "rb") as f: + pdf_bytes = f.read() + pdf_str = base64.b64encode(pdf_bytes).decode() + + # Test different base64 formats + formats = [ + f"data:application/pdf;base64,{pdf_str}", + ] + + for fmt in formats: + source = PdfSource(fmt) + assert isinstance(source.root, bytes) + assert len(source.root) > 0 diff --git a/tests/unit/utils/test_image_utils.py b/tests/unit/utils/test_image_utils.py index f739655a..b11e493e 100644 --- a/tests/unit/utils/test_image_utils.py +++ b/tests/unit/utils/test_image_utils.py @@ -3,6 +3,7 @@ import pytest from PIL import Image +from pydantic import ValidationError from askui.utils.image_utils import ( ImageSource, @@ -12,34 +13,33 @@ draw_point_on_image, image_to_base64, image_to_data_url, - load_image, scale_coordinates, scale_image_to_fit, ) -class TestLoadImage: - def test_load_image_from_pil( +class TestImageSource: + def test_image_source_from_pil( self, path_fixtures_github_com__icon: pathlib.Path ) -> None: img = Image.open(path_fixtures_github_com__icon) - loaded = load_image(img) - assert loaded == img + source = ImageSource(img) + assert source.root == img - def test_load_image_from_path( + def test_image_source_from_path( self, path_fixtures_github_com__icon: pathlib.Path ) -> None: # Test loading from Path - loaded = load_image(path_fixtures_github_com__icon) - assert isinstance(loaded, Image.Image) - assert loaded.size == (128, 125) # GitHub icon size + source = ImageSource(path_fixtures_github_com__icon) + assert isinstance(source.root, Image.Image) + assert source.root.size == (128, 125) # GitHub icon size # Test loading from str path - loaded = load_image(str(path_fixtures_github_com__icon)) - assert isinstance(loaded, Image.Image) - assert loaded.size == (128, 125) + source = ImageSource(str(path_fixtures_github_com__icon)) + assert isinstance(source.root, Image.Image) + assert source.root.size == (128, 125) - def test_load_image_from_base64( + def test_image_source_from_data_url( self, path_fixtures_github_com__icon: pathlib.Path ) -> None: # Load test image and convert to base64 @@ -50,71 +50,40 @@ def test_load_image_from_base64( # Test different base64 formats formats = [ f"data:image/png;base64,{img_str}", - f"data:;base64,{img_str}", - f"data:,{img_str}", - f",{img_str}", ] for fmt in formats: - loaded = load_image(fmt) - assert isinstance(loaded, Image.Image) - assert loaded.size == (128, 125) + source = ImageSource(fmt) + assert isinstance(source.root, Image.Image) + assert source.root.size == (128, 125) - def test_load_image_invalid( + def test_image_source_invalid( self, path_fixtures_github_com__icon: pathlib.Path ) -> None: - with pytest.raises(ValueError): - load_image("invalid_path.png") + with pytest.raises(ValidationError): + ImageSource("invalid_path.png") - with pytest.raises(ValueError): - load_image("invalid_base64") + with pytest.raises(ValidationError): + ImageSource("invalid_base64") - with pytest.raises(ValueError): + with pytest.raises(OSError): with pathlib.Path.open(path_fixtures_github_com__icon, "rb") as f: img_bytes = f.read() img_str = base64.b64encode(img_bytes).decode() - load_image(img_str) - - def test_load_image_nonexistent_file(self) -> None: - with pytest.raises(ValueError, match="Could not open image from file path"): - load_image("nonexistent_file.png") + ImageSource(img_str) - -class TestImageSource: - def test_image_source(self, path_fixtures_github_com__icon: pathlib.Path) -> None: - # Test with PIL Image - img = Image.open(path_fixtures_github_com__icon) - source = ImageSource(root=img) - assert source.root == img - - # Test with path - source = ImageSource(root=path_fixtures_github_com__icon) - assert isinstance(source.root, Image.Image) - assert source.root.size == (128, 125) - - # Test with base64 - with pathlib.Path.open(path_fixtures_github_com__icon, "rb") as f: - img_bytes = f.read() - img_str = base64.b64encode(img_bytes).decode() - source = ImageSource(root=f"data:image/png;base64,{img_str}") - assert isinstance(source.root, Image.Image) - assert source.root.size == (128, 125) - - def test_image_source_invalid(self) -> None: - with pytest.raises(ValueError): - ImageSource(root="invalid_path.png") - - with pytest.raises(ValueError): - ImageSource(root="invalid_base64") + def test_image_source_nonexistent_file(self) -> None: + with pytest.raises(ValidationError, match="No such file or directory"): + ImageSource("nonexistent_file.png") def test_to_data_url(self, path_fixtures_github_com__icon: pathlib.Path) -> None: - source = ImageSource(root=path_fixtures_github_com__icon) + source = ImageSource(path_fixtures_github_com__icon) data_url = source.to_data_url() assert data_url.startswith("data:image/png;base64,") assert len(data_url) > 100 # Should have some base64 content def test_to_base64(self, path_fixtures_github_com__icon: pathlib.Path) -> None: - source = ImageSource(root=path_fixtures_github_com__icon) + source = ImageSource(path_fixtures_github_com__icon) base64_str = source.to_base64() assert len(base64_str) > 100 # Should have some base64 content From 91e880c0dd075fcad24ac5ac025e9ee69e3100d9 Mon Sep 17 00:00:00 2001 From: danyalxahid-askui Date: Fri, 15 Aug 2025 00:38:12 +0200 Subject: [PATCH 08/10] fix(get_model): restrict model choices for PDF processing - Added `ModelName.ASKUI` to the list of restricted models in the `get` method of `AskUiGetModel` to prevent unsupported PDF processing. - Updated the test `test_get_with_pdf_too_large_with_default_model` to raise a `ValueError` instead of `NotImplementedError` when a large PDF is processed with the default model, ensuring accurate error handling. --- src/askui/models/askui/get_model.py | 1 + tests/e2e/agent/test_get.py | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/askui/models/askui/get_model.py b/src/askui/models/askui/get_model.py index f3b941c3..0b766e36 100644 --- a/src/askui/models/askui/get_model.py +++ b/src/askui/models/askui/get_model.py @@ -46,6 +46,7 @@ def get( ) -> ResponseSchema | str: if isinstance(source, PdfSource): if model_choice not in [ + ModelName.ASKUI, ModelName.ASKUI__GEMINI__2_5__FLASH, ModelName.ASKUI__GEMINI__2_5__PRO, ]: diff --git a/tests/e2e/agent/test_get.py b/tests/e2e/agent/test_get.py index 50a0da4b..d3a54119 100644 --- a/tests/e2e/agent/test_get.py +++ b/tests/e2e/agent/test_get.py @@ -118,9 +118,7 @@ def test_get_with_pdf_too_large_with_default_model( # This should raise a ValueError because the default model is Gemini and it falls # back to inference askui which does not support pdfs - with pytest.raises( - NotImplementedError, match="PDF processing is not supported for the model" - ): + with pytest.raises(ValueError, match="PDF file size exceeds the limit"): vision_agent.get( "What is in the PDF?", source=path_fixtures_dummy_pdf, From e168cc383c407d8e96d325e56325cbc6b648a7c2 Mon Sep 17 00:00:00 2001 From: Adrian Stritzinger Date: Fri, 15 Aug 2025 11:02:09 +0200 Subject: [PATCH 09/10] refactor(load_source): make source loading more modular and readable --- src/askui/agent_base.py | 4 +- src/askui/locators/locators.py | 4 +- src/askui/models/anthropic/messages_api.py | 2 +- src/askui/models/askui/get_model.py | 2 +- src/askui/models/askui/google_genai_api.py | 13 +- src/askui/models/askui/inference_api.py | 2 +- src/askui/models/model_router.py | 2 +- src/askui/models/models.py | 2 +- src/askui/models/openrouter/model.py | 2 +- src/askui/models/shared/facade.py | 2 +- src/askui/models/ui_tars_ep/ui_tars_api.py | 2 +- src/askui/utils/file_utils.py | 49 ------ src/askui/utils/image_utils.py | 32 +--- src/askui/utils/io_utils.py | 77 ---------- src/askui/utils/pdf_utils.py | 21 ++- src/askui/utils/source_utils.py | 170 +++++++++++++++++++++ tests/integration/test_custom_models.py | 2 +- tests/integration/utils/test_pdf_utils.py | 34 ++--- tests/unit/locators/test_locators.py | 2 +- tests/unit/utils/test_image_utils.py | 62 +------- tests/unit/utils/test_source_utils.py | 56 +++++++ 21 files changed, 268 insertions(+), 274 deletions(-) delete mode 100644 src/askui/utils/file_utils.py delete mode 100644 src/askui/utils/io_utils.py create mode 100644 src/askui/utils/source_utils.py create mode 100644 tests/unit/utils/test_source_utils.py diff --git a/src/askui/agent_base.py b/src/askui/agent_base.py index 4c6f7eb0..88d8e517 100644 --- a/src/askui/agent_base.py +++ b/src/askui/agent_base.py @@ -16,9 +16,9 @@ from askui.models.shared.tools import Tool from askui.tools.agent_os import AgentOs from askui.tools.android.agent_os import AndroidAgentOs -from askui.utils.file_utils import load_source from askui.utils.image_utils import ImageSource, Img from askui.utils.pdf_utils import Pdf +from askui.utils.source_utils import load_image_source, load_source from .logger import configure_logging, logger from .models import ModelComposition @@ -359,7 +359,7 @@ def _locate( model: ModelComposition | str | None = None, ) -> Point: def locate_with_screenshot() -> Point: - _screenshot = ImageSource( + _screenshot = load_image_source( self._agent_os.screenshot() if screenshot is None else screenshot ) return self._model_router.locate( diff --git a/src/askui/locators/locators.py b/src/askui/locators/locators.py index 652c08b0..bc552a1c 100644 --- a/src/askui/locators/locators.py +++ b/src/askui/locators/locators.py @@ -7,7 +7,7 @@ from pydantic import ConfigDict, Field, validate_call from askui.locators.relatable import Relatable -from askui.utils.image_utils import ImageSource +from askui.utils.source_utils import load_image_source TextMatchType = Literal["similar", "exact", "contains", "regex"] """The type of match to use. @@ -303,7 +303,7 @@ def __init__( image_compare_format=image_compare_format, name=_generate_name() if name is None else name, ) - self._image = ImageSource(image) + self._image = load_image_source(image) class AiElement(ImageBase): diff --git a/src/askui/models/anthropic/messages_api.py b/src/askui/models/anthropic/messages_api.py index e98c3d14..dfde5dfc 100644 --- a/src/askui/models/anthropic/messages_api.py +++ b/src/askui/models/anthropic/messages_api.py @@ -42,7 +42,6 @@ from askui.models.shared.tools import ToolCollection from askui.models.types.response_schemas import ResponseSchema from askui.utils.dict_utils import IdentityDefaultDict -from askui.utils.file_utils import Source from askui.utils.image_utils import ( ImageSource, image_to_base64, @@ -50,6 +49,7 @@ scale_image_to_fit, ) from askui.utils.pdf_utils import PdfSource +from askui.utils.source_utils import Source from .utils import extract_click_coordinates diff --git a/src/askui/models/askui/get_model.py b/src/askui/models/askui/get_model.py index 0b766e36..06081a33 100644 --- a/src/askui/models/askui/get_model.py +++ b/src/askui/models/askui/get_model.py @@ -9,8 +9,8 @@ from askui.models.exceptions import QueryNoResponseError, QueryUnexpectedResponseError from askui.models.models import GetModel, ModelName from askui.models.types.response_schemas import ResponseSchema -from askui.utils.file_utils import Source from askui.utils.pdf_utils import PdfSource +from askui.utils.source_utils import Source class AskUiGetModel(GetModel): diff --git a/src/askui/models/askui/google_genai_api.py b/src/askui/models/askui/google_genai_api.py index 95f8c864..8d691023 100644 --- a/src/askui/models/askui/google_genai_api.py +++ b/src/askui/models/askui/google_genai_api.py @@ -21,10 +21,9 @@ from askui.models.models import GetModel, ModelName from askui.models.shared.prompts import SYSTEM_PROMPT_GET from askui.models.types.response_schemas import ResponseSchema, to_response_schema -from askui.utils.file_utils import Source from askui.utils.http_utils import parse_retry_after_header from askui.utils.image_utils import ImageSource -from askui.utils.pdf_utils import PdfSource +from askui.utils.source_utils import Source ASKUI_MODEL_CHOICE_PREFIX = "askui/" ASKUI_MODEL_CHOICE_PREFIX_LEN = len(ASKUI_MODEL_CHOICE_PREFIX) @@ -186,8 +185,8 @@ def _create_genai_part_from_source(self, source: Source) -> genai_types.Part: data=data, mime_type="image/png", ) - if isinstance(source, PdfSource): - data = source.root + with source.reader as r: + data = r.read() if len(data) > MAX_FILE_SIZE_BYTES: _err_msg = ( f"PDF file size exceeds the limit of {MAX_FILE_SIZE_BYTES} bytes." @@ -197,9 +196,3 @@ def _create_genai_part_from_source(self, source: Source) -> genai_types.Part: data=data, mime_type="application/pdf", ) - # Explicit error for unsupported source types (for future extensibility) - err_msg = ( # type: ignore[unreachable] - f"Unsupported source type: {type(source).__name__}. " - "Only ImageSource and PdfSource are supported." - ) - raise NotImplementedError(err_msg) diff --git a/src/askui/models/askui/inference_api.py b/src/askui/models/askui/inference_api.py index 6b22efc3..b30d40cb 100644 --- a/src/askui/models/askui/inference_api.py +++ b/src/askui/models/askui/inference_api.py @@ -26,9 +26,9 @@ from askui.models.shared.settings import MessageSettings from askui.models.shared.tools import ToolCollection from askui.models.types.response_schemas import ResponseSchema -from askui.utils.file_utils import Source from askui.utils.image_utils import ImageSource from askui.utils.pdf_utils import PdfSource +from askui.utils.source_utils import Source from ..types.response_schemas import to_response_schema diff --git a/src/askui/models/model_router.py b/src/askui/models/model_router.py index 27efb719..394e7a3e 100644 --- a/src/askui/models/model_router.py +++ b/src/askui/models/model_router.py @@ -31,8 +31,8 @@ from askui.models.shared.tools import Tool from askui.models.types.response_schemas import ResponseSchema from askui.reporting import NULL_REPORTER, CompositeReporter, Reporter -from askui.utils.file_utils import Source from askui.utils.image_utils import ImageSource +from askui.utils.source_utils import Source from ..logger import logger from .askui.inference_api import AskUiInferenceApi diff --git a/src/askui/models/models.py b/src/askui/models/models.py index d5612228..22420da9 100644 --- a/src/askui/models/models.py +++ b/src/askui/models/models.py @@ -13,8 +13,8 @@ from askui.models.shared.settings import ActSettings from askui.models.shared.tools import Tool from askui.models.types.response_schemas import ResponseSchema -from askui.utils.file_utils import Source from askui.utils.image_utils import ImageSource +from askui.utils.source_utils import Source class ModelName(str, Enum): diff --git a/src/askui/models/openrouter/model.py b/src/askui/models/openrouter/model.py index fc379589..a5a6882c 100644 --- a/src/askui/models/openrouter/model.py +++ b/src/askui/models/openrouter/model.py @@ -10,8 +10,8 @@ from askui.models.models import GetModel from askui.models.shared.prompts import SYSTEM_PROMPT_GET from askui.models.types.response_schemas import ResponseSchema, to_response_schema -from askui.utils.file_utils import Source from askui.utils.pdf_utils import PdfSource +from askui.utils.source_utils import Source from .settings import OpenRouterSettings diff --git a/src/askui/models/shared/facade.py b/src/askui/models/shared/facade.py index 32e62dca..a26c9cfd 100644 --- a/src/askui/models/shared/facade.py +++ b/src/askui/models/shared/facade.py @@ -9,8 +9,8 @@ from askui.models.shared.settings import ActSettings from askui.models.shared.tools import Tool from askui.models.types.response_schemas import ResponseSchema -from askui.utils.file_utils import Source from askui.utils.image_utils import ImageSource +from askui.utils.source_utils import Source class ModelFacade(ActModel, GetModel, LocateModel): diff --git a/src/askui/models/ui_tars_ep/ui_tars_api.py b/src/askui/models/ui_tars_ep/ui_tars_api.py index fedf4dde..84cf46a1 100644 --- a/src/askui/models/ui_tars_ep/ui_tars_api.py +++ b/src/askui/models/ui_tars_ep/ui_tars_api.py @@ -18,9 +18,9 @@ from askui.models.shared.tools import Tool from askui.models.types.response_schemas import ResponseSchema from askui.reporting import Reporter -from askui.utils.file_utils import Source from askui.utils.image_utils import ImageSource, image_to_base64 from askui.utils.pdf_utils import PdfSource +from askui.utils.source_utils import Source from .parser import UITarsEPMessage from .prompts import PROMPT, PROMPT_QA diff --git a/src/askui/utils/file_utils.py b/src/askui/utils/file_utils.py deleted file mode 100644 index 304b9c9b..00000000 --- a/src/askui/utils/file_utils.py +++ /dev/null @@ -1,49 +0,0 @@ -from pathlib import Path -from typing import Union - -from PIL import Image as PILImage - -from askui.utils.image_utils import ImageSource -from askui.utils.io_utils import source_file_type -from askui.utils.pdf_utils import PdfSource - -# to avoid circular imports from image_utils and pdf_utils on read_bytes -Source = Union[ImageSource, PdfSource] - -ALLOWED_IMAGE_TYPES = [ - "image/png", - "image/jpeg", - "image/gif", - "image/webp", -] - -PDF_TYPE = "application/pdf" - -ALLOWED_MIMETYPES = [PDF_TYPE] + ALLOWED_IMAGE_TYPES - - -def load_source(source: Union[str, Path, PILImage.Image]) -> Source: - """Load a source and return it as an ImageSource or PdfSource. - - Args: - source (Union[str, Path]): The source to load. - - Returns: - Source: The loaded source as an ImageSource or PdfSource. - - Raises: - ValueError: If the source is not a valid image or PDF file. - """ - if isinstance(source, PILImage.Image): - return ImageSource(source) - - file_type = source_file_type(source) - if file_type in ALLOWED_IMAGE_TYPES: - return ImageSource(source) - if file_type == PDF_TYPE: - return PdfSource(source) - msg = f"Unsupported file type: {file_type}" - raise ValueError(msg) - - -__all__ = ["Source", "load_source"] diff --git a/src/askui/utils/image_utils.py b/src/askui/utils/image_utils.py index 8dc83b7d..4f166579 100644 --- a/src/askui/utils/image_utils.py +++ b/src/askui/utils/image_utils.py @@ -2,29 +2,13 @@ import binascii import io import pathlib -import re from dataclasses import dataclass from pathlib import Path -from typing import Any, Literal, Union +from typing import Literal, Union from PIL import Image, ImageDraw, UnidentifiedImageError from PIL import Image as PILImage -from pydantic import ConfigDict, RootModel, field_validator - -from askui.utils.io_utils import read_bytes - -# Regex to capture any kind of valid base64 data url (with optional media type and ;base64) -# e.g., data:image/png;base64,... or data:;base64,... or data:,... or just ,... -_DATA_URL_GENERIC_RE = re.compile(r"^(?:data:)?[^,]*?,(.*)$", re.DOTALL) - - -def _bytes_to_image(image_bytes: bytes) -> Image.Image: - """Convert bytes to a PIL Image.""" - try: - return Image.open(io.BytesIO(image_bytes)) - except (FileNotFoundError, UnidentifiedImageError) as e: - error_msg = "Could not identify image from bytes" - raise ValueError(error_msg) from e +from pydantic import ConfigDict, RootModel def image_to_data_url(image: PILImage.Image) -> str: @@ -354,18 +338,6 @@ class ImageSource(RootModel): model_config = ConfigDict(arbitrary_types_allowed=True) root: PILImage.Image - def __init__(self, root: Img, **kwargs: dict[str, Any]) -> None: - super().__init__(root=root, **kwargs) - - @field_validator("root", mode="before") - @classmethod - def validate_root(cls, v: Any) -> Image.Image: - if isinstance(v, Image.Image): - return v - - image_bytes = read_bytes(v) - return _bytes_to_image(image_bytes) - def to_data_url(self) -> str: """Convert the image to a data URL. diff --git a/src/askui/utils/io_utils.py b/src/askui/utils/io_utils.py deleted file mode 100644 index b6a918de..00000000 --- a/src/askui/utils/io_utils.py +++ /dev/null @@ -1,77 +0,0 @@ -import base64 -import binascii -import re -from pathlib import Path -from typing import Union - -from filetype import guess # type: ignore[import-untyped] - -_DATA_URL_WITH_MIMETYPE_RE = re.compile(r"^data:([^;,]+)[^,]*?,(.*)$", re.DOTALL) - - -def source_file_type(source: Union[str, Path]) -> str: - """Determines the MIME type of a source. - - The source can be a file path or a data URL. - - Args: - source (Union[str , Path]): The source to determine the type of. - Can be a file path (`str` or `pathlib.Path`) or a data URL. - - Returns: - str: The MIME type of the source, or "unknown" if it cannot be determined. - """ - - # when source is a data url - if isinstance(source, str) and source.startswith("data:"): - match = _DATA_URL_WITH_MIMETYPE_RE.match(source) - if match and match.group(1): - return match.group(1) - else: - kind = guess(str(source)) - if kind is not None and kind.mime is not None: - return str(kind.mime) - - return "unknown" - - -def read_bytes(source: Union[str, Path]) -> bytes: - """Read the bytes of a source. - - The source can be a file path or a data URL. - - Args: - source (Union[str, Path]): The source to read the bytes from. - - Returns: - bytes: The content of the source as bytes. - """ - # when source is a file path and not a data url - if isinstance(source, Path) or ( - isinstance(source, str) and not source.startswith(("data:", ",")) - ): - filepath = Path(source) - if not filepath.is_file(): - err_msg = f"No such file or directory: '{source}'" - raise ValueError(err_msg) - - return filepath.read_bytes() - - # when source is a data url - if isinstance(source, str) and source.startswith(("data:", ",")): - match = _DATA_URL_WITH_MIMETYPE_RE.match(source) - if match: - try: - return base64.b64decode(match.group(2)) - except binascii.Error as e: - error_msg = ( - "Could not decode base64 data from input: " - f"{source[:100]}{'...' if len(source) > 100 else ''}" - ) - raise ValueError(error_msg) from e - - msg = f"Unsupported source type: {type(source)}" - raise ValueError(msg) - - -__all__ = ["read_bytes"] diff --git a/src/askui/utils/pdf_utils.py b/src/askui/utils/pdf_utils.py index 98c59ab9..2df0246d 100644 --- a/src/askui/utils/pdf_utils.py +++ b/src/askui/utils/pdf_utils.py @@ -1,9 +1,8 @@ +from io import BufferedReader, BytesIO from pathlib import Path -from typing import Any, Union +from typing import Union -from pydantic import ConfigDict, RootModel, field_validator - -from askui.utils.io_utils import read_bytes +from pydantic import ConfigDict, RootModel Pdf = Union[str, Path] """Type of the input PDFs for `askui.VisionAgent.get()`, etc. @@ -28,15 +27,13 @@ class PdfSource(RootModel): """ model_config = ConfigDict(arbitrary_types_allowed=True) - root: bytes - - def __init__(self, root: Pdf, **kwargs: dict[str, Any]) -> None: - super().__init__(root=root, **kwargs) + root: bytes | Path - @field_validator("root", mode="before") - @classmethod - def validate_root(cls, v: Any) -> bytes: - return read_bytes(v) + @property + def reader(self) -> BufferedReader | BytesIO: + if isinstance(self.root, Path): + return self.root.open("rb") + return BytesIO(self.root) __all__ = [ diff --git a/src/askui/utils/source_utils.py b/src/askui/utils/source_utils.py new file mode 100644 index 00000000..619f134a --- /dev/null +++ b/src/askui/utils/source_utils.py @@ -0,0 +1,170 @@ +import base64 +import re +from dataclasses import dataclass +from enum import Enum +from io import BytesIO +from pathlib import Path +from typing import Literal, Union + +from filetype import guess # type: ignore[import-untyped] +from PIL import Image as PILImage + +from askui.utils.image_utils import ImageSource +from askui.utils.pdf_utils import PdfSource + +Source = Union[ImageSource, PdfSource] + +_DATA_URL_WITH_MIMETYPE_RE = re.compile(r"^data:([^;,]+)([^,]*)?,(.*)$", re.DOTALL) + +_SupportedImageMimeTypes = Literal["image/png", "image/jpeg", "image/gif", "image/webp"] +_SupportedApplicationMimeTypes = Literal["application/pdf"] +_SupportedMimeTypes = _SupportedImageMimeTypes | _SupportedApplicationMimeTypes + +_SUPPORTED_MIME_TYPES: list[_SupportedMimeTypes] = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "application/pdf", +] + + +class _SourceType(Enum): + DATA_URL = "data_url" + FILE = "file" + UNKNOWN = "unknown" + + +@dataclass +class _SourceAnalysis: + type: _SourceType = _SourceType.UNKNOWN + mime: str | None = None + content: Path | bytes | None = None + + @property + def is_supported(self) -> bool: + return bool(self.mime) and self.mime in _SUPPORTED_MIME_TYPES + + @property + def is_pdf(self) -> bool: + return self.mime == "application/pdf" + + @property + def is_image(self) -> bool: + if self.mime: + return self.mime.startswith("image/") + return False + + +def _analyze_data_url(source: str) -> _SourceAnalysis | None: + if ( + (match := _DATA_URL_WITH_MIMETYPE_RE.match(source)) + and (mime := match.group(1)) + and (is_base64 := match.group(2) == ";base64") + and (data := match.group(3)) + ): + data_decoded = base64.b64decode(data) if is_base64 else data.encode() + return _SourceAnalysis( + type=_SourceType.DATA_URL, + mime=mime, + content=data_decoded, + ) + return None + + +def _analyze_file(source: str | Path) -> _SourceAnalysis | None: + if (kind := guess(str(source))) and (mime := kind.mime): + return _SourceAnalysis( + type=_SourceType.FILE, + mime=mime, + content=Path(source), + ) + return None + + +def _analyze_source(source: Union[str, Path]) -> _SourceAnalysis: + """Analyze a source (data url (`str`) or file path (`str` or `Path`)). + + Args: + source (Union[str, Path]): The source to analyze. + + Returns: + SourceAnalysis: The analysis of the source. + + Raises: + binascii.Error: If the data within data url cannot be decoded. + FileNotFoundError: If the source is regarded to be a file path and does not + exist. + """ + if isinstance(source, str) and (result := _analyze_data_url(source)): + return result + if result := _analyze_file(source): + return result + return _SourceAnalysis(type=_SourceType.UNKNOWN) + + +def load_source(source: Union[str, Path, PILImage.Image]) -> Source: + """Load a source and return it as an ImageSource or PdfSource. + + Args: + source (Union[str, Path]): The source to load. + + Returns: + Source: The loaded source as an ImageSource or PdfSource. + + Raises: + ValueError: If the source is not a valid image or PDF file. + FileNotFoundError: If the source is regarded to be a file path and does not + exist. + binascii.Error: If the data within data url cannot be decoded. + """ + + if isinstance(source, PILImage.Image): + return ImageSource(source) + source_analysis = _analyze_source(source) + if not source_analysis.is_supported: + msg = ( + f"Unsupported mime type: {source_analysis.mime} " + f"(supported: {_SUPPORTED_MIME_TYPES})" + ) + raise ValueError(msg) + if not source_analysis.content: + msg = "No content to read from" + raise ValueError(msg) + if source_analysis.is_pdf: + return PdfSource(source_analysis.content) + if source_analysis.is_image: + return ImageSource( + PILImage.open( + BytesIO(source_analysis.content) + if isinstance(source_analysis.content, bytes) + else source_analysis.content + ) + ) + msg = "Unsupported source type" + raise ValueError(msg) + + +def load_image_source(source: Union[str, Path, PILImage.Image]) -> ImageSource: + """Load a source and return it as an ImageSource. + + Args: + source (Union[str, Path]): The source to load. + + Returns: + ImageSource: The loaded source. + + Raises: + ValueError: If the source is not a valid image. + FileNotFoundError: If the source is regarded to be a file path and does not + exist. + binascii.Error: If the data within data url cannot be decoded. + """ + result = load_source(source) + if not isinstance(result, ImageSource): + msg = "Source is not an image" + raise TypeError(msg) + return result + + +__all__ = ["Source", "load_source", "load_image_source"] diff --git a/tests/integration/test_custom_models.py b/tests/integration/test_custom_models.py index 8bec2371..962def95 100644 --- a/tests/integration/test_custom_models.py +++ b/tests/integration/test_custom_models.py @@ -23,8 +23,8 @@ from askui.models.shared.settings import ActSettings from askui.models.shared.tools import Tool from askui.tools.toolbox import AgentToolbox -from askui.utils.file_utils import Source from askui.utils.image_utils import ImageSource +from askui.utils.source_utils import Source class SimpleActModel(ActModel): diff --git a/tests/integration/utils/test_pdf_utils.py b/tests/integration/utils/test_pdf_utils.py index 2947a126..7c7e52b3 100644 --- a/tests/integration/utils/test_pdf_utils.py +++ b/tests/integration/utils/test_pdf_utils.py @@ -3,24 +3,19 @@ import pytest -from askui.utils.file_utils import PdfSource +from askui.utils.source_utils import load_source class TestLoadPdf: def test_load_pdf_from_path(self, path_fixtures_dummy_pdf: pathlib.Path) -> None: - # Test loading from Path - loaded = PdfSource(path_fixtures_dummy_pdf) - assert isinstance(loaded.root, bytes) - assert len(loaded.root) > 0 - - # Test loading from str path - loaded = PdfSource(str(path_fixtures_dummy_pdf)) - assert isinstance(loaded.root, bytes) - assert len(loaded.root) > 0 + loaded = load_source(path_fixtures_dummy_pdf) + assert isinstance(loaded.root, bytes | pathlib.Path) + with loaded.reader as r: + assert len(r.read()) > 0 def test_load_pdf_nonexistent_file(self) -> None: - with pytest.raises(ValueError): - PdfSource("nonexistent_file.pdf") + with pytest.raises(FileNotFoundError): + load_source("nonexistent_file.pdf") def test_pdf_source_from_data_url( self, path_fixtures_dummy_pdf: pathlib.Path @@ -29,13 +24,8 @@ def test_pdf_source_from_data_url( with pathlib.Path.open(path_fixtures_dummy_pdf, "rb") as f: pdf_bytes = f.read() pdf_str = base64.b64encode(pdf_bytes).decode() - - # Test different base64 formats - formats = [ - f"data:application/pdf;base64,{pdf_str}", - ] - - for fmt in formats: - source = PdfSource(fmt) - assert isinstance(source.root, bytes) - assert len(source.root) > 0 + data_url = f"data:application/pdf;base64,{pdf_str}" + source = load_source(data_url) + assert isinstance(source.root, bytes | pathlib.Path) + with source.reader as r: + assert len(r.read()) > 0 diff --git a/tests/unit/locators/test_locators.py b/tests/unit/locators/test_locators.py index 7aee9024..29af0dff 100644 --- a/tests/unit/locators/test_locators.py +++ b/tests/unit/locators/test_locators.py @@ -158,7 +158,7 @@ def test_initialization_with_custom_params( ) def test_initialization_with_invalid_args(self, test_image: PILImage.Image) -> None: - with pytest.raises(ValueError): + with pytest.raises(FileNotFoundError): Image(image="not_an_image") with pytest.raises(ValueError): diff --git a/tests/unit/utils/test_image_utils.py b/tests/unit/utils/test_image_utils.py index b11e493e..9cf23116 100644 --- a/tests/unit/utils/test_image_utils.py +++ b/tests/unit/utils/test_image_utils.py @@ -3,7 +3,6 @@ import pytest from PIL import Image -from pydantic import ValidationError from askui.utils.image_utils import ( ImageSource, @@ -19,71 +18,14 @@ class TestImageSource: - def test_image_source_from_pil( - self, path_fixtures_github_com__icon: pathlib.Path - ) -> None: - img = Image.open(path_fixtures_github_com__icon) - source = ImageSource(img) - assert source.root == img - - def test_image_source_from_path( - self, path_fixtures_github_com__icon: pathlib.Path - ) -> None: - # Test loading from Path - source = ImageSource(path_fixtures_github_com__icon) - assert isinstance(source.root, Image.Image) - assert source.root.size == (128, 125) # GitHub icon size - - # Test loading from str path - source = ImageSource(str(path_fixtures_github_com__icon)) - assert isinstance(source.root, Image.Image) - assert source.root.size == (128, 125) - - def test_image_source_from_data_url( - self, path_fixtures_github_com__icon: pathlib.Path - ) -> None: - # Load test image and convert to base64 - with pathlib.Path.open(path_fixtures_github_com__icon, "rb") as f: - img_bytes = f.read() - img_str = base64.b64encode(img_bytes).decode() - - # Test different base64 formats - formats = [ - f"data:image/png;base64,{img_str}", - ] - - for fmt in formats: - source = ImageSource(fmt) - assert isinstance(source.root, Image.Image) - assert source.root.size == (128, 125) - - def test_image_source_invalid( - self, path_fixtures_github_com__icon: pathlib.Path - ) -> None: - with pytest.raises(ValidationError): - ImageSource("invalid_path.png") - - with pytest.raises(ValidationError): - ImageSource("invalid_base64") - - with pytest.raises(OSError): - with pathlib.Path.open(path_fixtures_github_com__icon, "rb") as f: - img_bytes = f.read() - img_str = base64.b64encode(img_bytes).decode() - ImageSource(img_str) - - def test_image_source_nonexistent_file(self) -> None: - with pytest.raises(ValidationError, match="No such file or directory"): - ImageSource("nonexistent_file.png") - def test_to_data_url(self, path_fixtures_github_com__icon: pathlib.Path) -> None: - source = ImageSource(path_fixtures_github_com__icon) + source = ImageSource(Image.open(path_fixtures_github_com__icon)) data_url = source.to_data_url() assert data_url.startswith("data:image/png;base64,") assert len(data_url) > 100 # Should have some base64 content def test_to_base64(self, path_fixtures_github_com__icon: pathlib.Path) -> None: - source = ImageSource(path_fixtures_github_com__icon) + source = ImageSource(Image.open(path_fixtures_github_com__icon)) base64_str = source.to_base64() assert len(base64_str) > 100 # Should have some base64 content diff --git a/tests/unit/utils/test_source_utils.py b/tests/unit/utils/test_source_utils.py new file mode 100644 index 00000000..fb72925c --- /dev/null +++ b/tests/unit/utils/test_source_utils.py @@ -0,0 +1,56 @@ +import base64 +import pathlib + +import pytest +from PIL import Image + +from askui.utils.source_utils import load_image_source + + +class TestLoadImageSource: + def test_image_source_from_pil( + self, path_fixtures_github_com__icon: pathlib.Path + ) -> None: + source = load_image_source(path_fixtures_github_com__icon) + assert source.root == Image.open(path_fixtures_github_com__icon) + + def test_image_source_from_path( + self, path_fixtures_github_com__icon: pathlib.Path + ) -> None: + # Test loading from Path + source = load_image_source(path_fixtures_github_com__icon) + assert isinstance(source.root, Image.Image) + assert source.root.size == (128, 125) # GitHub icon size + + # Test loading from str path + source = load_image_source(str(path_fixtures_github_com__icon)) + assert isinstance(source.root, Image.Image) + assert source.root.size == (128, 125) + + def test_image_source_from_data_url( + self, path_fixtures_github_com__icon: pathlib.Path + ) -> None: + # Load test image and convert to base64 + with pathlib.Path.open(path_fixtures_github_com__icon, "rb") as f: + img_bytes = f.read() + img_str = base64.b64encode(img_bytes).decode() + data_url = f"data:image/png;base64,{img_str}" + + source = load_image_source(data_url) + assert isinstance(source.root, Image.Image) + assert source.root.size == (128, 125) + + def test_image_source_invalid( + self, path_fixtures_github_com__icon: pathlib.Path + ) -> None: + with pytest.raises(FileNotFoundError): + load_image_source("invalid_path.png") + + with pytest.raises(FileNotFoundError): + load_image_source("invalid_base64") + + with pytest.raises(OSError): + with pathlib.Path.open(path_fixtures_github_com__icon, "rb") as f: + img_bytes = f.read() + img_str = base64.b64encode(img_bytes).decode() + load_image_source(img_str) From 252d0a4a5ce1f5ad090855a73db3fb741f443a81 Mon Sep 17 00:00:00 2001 From: Adrian Stritzinger Date: Fri, 15 Aug 2025 11:07:19 +0200 Subject: [PATCH 10/10] chore(models): remove too restrictive check for pdf support (AskUiGetModel) --- src/askui/models/askui/get_model.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/askui/models/askui/get_model.py b/src/askui/models/askui/get_model.py index 06081a33..9534cf1a 100644 --- a/src/askui/models/askui/get_model.py +++ b/src/askui/models/askui/get_model.py @@ -7,9 +7,8 @@ from askui.models.askui.google_genai_api import AskUiGoogleGenAiApi from askui.models.askui.inference_api import AskUiInferenceApi from askui.models.exceptions import QueryNoResponseError, QueryUnexpectedResponseError -from askui.models.models import GetModel, ModelName +from askui.models.models import GetModel from askui.models.types.response_schemas import ResponseSchema -from askui.utils.pdf_utils import PdfSource from askui.utils.source_utils import Source @@ -44,16 +43,6 @@ def get( response_schema: Type[ResponseSchema] | None, model_choice: str, ) -> ResponseSchema | str: - if isinstance(source, PdfSource): - if model_choice not in [ - ModelName.ASKUI, - ModelName.ASKUI__GEMINI__2_5__FLASH, - ModelName.ASKUI__GEMINI__2_5__PRO, - ]: - err_msg = ( - f"PDF processing is not supported for the model '{model_choice}'" - ) - raise NotImplementedError(err_msg) try: logger.debug("Attempting to use Google GenAI API") return self._google_genai_api.get(