From bf8a8b5625cf5aa2c02e93ec80bb6caa9fe083aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ianar=C3=A9=20S=C3=A9vi?= Date: Thu, 20 Aug 2026 18:02:20 +0200 Subject: [PATCH 1/5] :sparkles: add RAG search API --- mindee/v2/client.py | 26 +- .../client_options/base_product_parameters.py | 4 +- .../client_options/base_search_parameters.py | 47 ++++ mindee/v2/mindee_http/mindee_api_v2.py | 188 ++++++-------- .../v2/mindee_http/response_validation_v2.py | 35 --- .../inference/base_inference_response.py | 2 +- mindee/v2/parsing/search/__init__.py | 4 + .../v2/parsing/search/base_search_response.py | 28 ++ mindee/v2/parsing/search/search_models.py | 2 +- .../v2/parsing/search/search_rag_document.py | 39 +++ .../v2/parsing/search/search_rag_documents.py | 27 ++ mindee/v2/parsing/search/search_response.py | 35 +-- .../classification/classification_response.py | 3 +- .../params/classification_parameters.py | 6 +- mindee/v2/product/crop/crop_response.py | 3 +- .../v2/product/crop/params/crop_parameters.py | 6 +- .../product/extraction/extraction_response.py | 3 +- .../params/extraction_parameters.py | 4 +- mindee/v2/product/ocr/ocr_response.py | 3 +- .../v2/product/ocr/params/ocr_parameters.py | 6 +- .../product/split/params/split_parameters.py | 2 +- mindee/v2/search/__init__.py | 0 mindee/v2/search/models/__init__.py | 0 .../search/models/model_search_parameters.py | 31 +++ .../v2/search/models/model_search_response.py | 18 ++ mindee/v2/search/rag_documents/__init__.py | 11 + .../rag_document_search_parameters.py | 31 +++ .../rag_document_search_response.py | 18 ++ tests/data | 2 +- tests/v2/search/test_model_search.py | 34 +++ .../search/test_model_search_integration.py | 33 +++ tests/v2/search/test_rag_document_search.py | 59 +++++ .../test_rag_document_search_integration.py | 26 ++ tests/v2/search/test_search_models.py | 33 --- tests/v2/test_client.py | 244 +++++++++--------- 35 files changed, 645 insertions(+), 368 deletions(-) create mode 100644 mindee/v2/client_options/base_search_parameters.py delete mode 100644 mindee/v2/mindee_http/response_validation_v2.py create mode 100644 mindee/v2/parsing/search/base_search_response.py create mode 100644 mindee/v2/parsing/search/search_rag_document.py create mode 100644 mindee/v2/parsing/search/search_rag_documents.py create mode 100644 mindee/v2/search/__init__.py create mode 100644 mindee/v2/search/models/__init__.py create mode 100644 mindee/v2/search/models/model_search_parameters.py create mode 100644 mindee/v2/search/models/model_search_response.py create mode 100644 mindee/v2/search/rag_documents/__init__.py create mode 100644 mindee/v2/search/rag_documents/rag_document_search_parameters.py create mode 100644 mindee/v2/search/rag_documents/rag_document_search_response.py create mode 100644 tests/v2/search/test_model_search.py create mode 100644 tests/v2/search/test_model_search_integration.py create mode 100644 tests/v2/search/test_rag_document_search.py create mode 100644 tests/v2/search/test_rag_document_search_integration.py delete mode 100644 tests/v2/search/test_search_models.py diff --git a/mindee/v2/client.py b/mindee/v2/client.py index 303ee741..83007baf 100644 --- a/mindee/v2/client.py +++ b/mindee/v2/client.py @@ -12,6 +12,10 @@ from mindee.mindee_http.cancellation_token import CancellationToken from mindee.parsing.common.common_response import CommonStatus from mindee.v2.client_options.base_product_parameters import BaseProductParameters +from mindee.v2.client_options.base_search_parameters import ( + BaseSearchParameters, + TypeSearchResponse, +) from mindee.v2.mindee_http.mindee_api_v2 import MindeeAPIV2 from mindee.v2.parsing.inference.base_inference_response import BaseInferenceResponse from mindee.v2.parsing.job.job_response import JobResponse @@ -57,7 +61,7 @@ def enqueue( :return: A valid inference response. """ logger.debug("Enqueuing inference using model: %s", params.model_id) - return self.mindee_api.enqueue(input_source, params) + return self.mindee_api.req_post_product_enqueue(input_source, params) def get_job(self, job_id: str) -> JobResponse: """ @@ -70,7 +74,7 @@ def get_job(self, job_id: str) -> JobResponse: """ logger.debug("Fetching job: %s", job_id) - return self.mindee_api.get_job(job_id) + return self.mindee_api.req_get_job_by_id(job_id) def get_result( self, @@ -78,7 +82,7 @@ def get_result( inference_id: str, ) -> TypeBaseInferenceResponse: """ - Get the result of an inference that was previously enqueued. + Get the result of an inference that was previously enqueued by its ID. The inference will only be available after it has finished processing. @@ -88,7 +92,7 @@ def get_result( """ logger.debug("Fetching result: %s", inference_id) - return self.mindee_api.get_result(response_type, inference_id) + return self.mindee_api.req_get_product_result_by_id(response_type, inference_id) def get_result_from_url( self, response_type: type[TypeBaseInferenceResponse], url: str @@ -100,7 +104,7 @@ def get_result_from_url( :param url: URL of the inference to retrieve. :return: The result of the inference. """ - return self.mindee_api.get_result_by_url(response_type, url) + return self.mindee_api.req_get_product_result_by_url(response_type, url) def enqueue_and_get_result( self, @@ -169,6 +173,16 @@ def enqueue_and_get_result( raise MindeeError(f"Couldn't retrieve document after {try_counter + 1} tries.") + def search( + self, params: BaseSearchParameters[TypeSearchResponse] + ) -> TypeSearchResponse: + """ + Search for resources matching the given criteria. + :param params: Search parameters + :return: A search response containing the matching resources + """ + return self.mindee_api.req_search(params) + def search_models( self, name: str | None = None, model_type: str | None = None ) -> SearchResponse: @@ -179,7 +193,7 @@ def search_models( :param model_type: Type of the model to filter by. :return: A list of models matching the provided criteria. """ - return self.mindee_api.get_models(name, model_type) + return self.mindee_api.req_get_search_models(name, model_type) def close(self) -> None: """Closes the underlying HTTP client.""" diff --git a/mindee/v2/client_options/base_product_parameters.py b/mindee/v2/client_options/base_product_parameters.py index 6e05f0f2..2a00f08f 100644 --- a/mindee/v2/client_options/base_product_parameters.py +++ b/mindee/v2/client_options/base_product_parameters.py @@ -7,7 +7,7 @@ @dataclass class BaseProductParameters(ABC): - """Base parameters for sending a document to a product.""" + """Base parameters for sending a file to a Mindee V2 product.""" model_id: str """Model ID to use for the inference. Required.""" @@ -32,7 +32,7 @@ class BaseProductParameters(ABC): """Whether to close the file after product.""" _slug: ClassVar[str] - """Slug of the endpoint.""" + """Slug of the product.""" def get_request_parameters(self) -> dict[str, str | list[str]]: """ diff --git a/mindee/v2/client_options/base_search_parameters.py b/mindee/v2/client_options/base_search_parameters.py new file mode 100644 index 00000000..2fe06c53 --- /dev/null +++ b/mindee/v2/client_options/base_search_parameters.py @@ -0,0 +1,47 @@ +from abc import ABC +from dataclasses import dataclass +from typing import ClassVar, Generic, TypeVar + +from mindee.v2.parsing.search.base_search_response import BaseSearchResponse + +TypeSearchResponse = TypeVar("TypeSearchResponse", bound=BaseSearchResponse) + + +@dataclass(kw_only=True) +class BaseSearchParameters(ABC, Generic[TypeSearchResponse]): + """Base parameters for searches.""" + + page: int | None = None + """1-based page index.""" + + per_page: int | None = None + """Number of items per page.""" + + _slug: ClassVar[str] + """Slug of the searchable resource.""" + + _response_class: type[TypeSearchResponse] + """Response class for the search.""" + + def get_request_parameters(self) -> dict[str, str | list[str]]: + """ + Gets the request parameters for the search request. + + :return: A dict of parameters. + """ + data: dict[str, str | list[str]] = {} + + if self.page is not None: + data["page"] = str(self.page) + if self.per_page is not None: + data["per_page"] = str(self.per_page) + + return data + + def get_slug(self) -> str: + """Gets the slug of the resource.""" + return self._slug + + def get_response_class(self) -> type[TypeSearchResponse]: + """Gets the response class for the search.""" + return self._response_class diff --git a/mindee/v2/mindee_http/mindee_api_v2.py b/mindee/v2/mindee_http/mindee_api_v2.py index 03663216..af122428 100644 --- a/mindee/v2/mindee_http/mindee_api_v2.py +++ b/mindee/v2/mindee_http/mindee_api_v2.py @@ -8,19 +8,20 @@ from mindee.input.local_input_source import LocalInputSource from mindee.input.url_input_source import URLInputSource from mindee.logger import logger +from mindee.mindee_http.response_validation import is_valid_sync_response from mindee.mindee_http.settings_mixin import SettingsMixin from mindee.parsing.common.string_dict import StringDict from mindee.v1.mindee_http.base_settings import USER_AGENT from mindee.v2.client_options.base_product_parameters import BaseProductParameters +from mindee.v2.client_options.base_search_parameters import ( + BaseSearchParameters, + TypeSearchResponse, +) from mindee.v2.error.mindee_api_v2_error import MindeeAPIV2Error from mindee.v2.error.mindee_http_error_v2 import ( MindeeHTTPUnknownErrorV2, handle_error_v2, ) -from mindee.v2.mindee_http.response_validation_v2 import ( - is_valid_get_response, - is_valid_post_response, -) from mindee.v2.parsing import BaseInferenceResponse from mindee.v2.parsing.job.job_response import JobResponse from mindee.v2.parsing.search.search_response import SearchResponse @@ -55,7 +56,7 @@ def __init__(self, api_key: str | None, http_client: httpx.Client | None = None) else os.environ.get(API_KEY_V2_ENV_NAME, API_KEY_V2_DEFAULT) ) self.set_base_url(BASE_URL_DEFAULT) - self.set_from_env() + self._set_from_env() if not self.api_key: raise MindeeAPIV2Error( f"Missing API key," @@ -77,7 +78,7 @@ def base_headers(self) -> dict[str, str]: "User-Agent": USER_AGENT, } - def set_from_env(self) -> None: + def _set_from_env(self) -> None: """Set various parameters from environment variables, if present.""" env_vars = { BASE_URL_ENV_NAME: self.set_base_url, @@ -89,22 +90,21 @@ def set_from_env(self) -> None: func(env_val) logger.debug("Value was set from env: %s", name) - def req_post_inference_enqueue( + def req_post_product_enqueue( self, input_source: LocalInputSource | URLInputSource, params: BaseProductParameters, - slug: str, - ) -> httpx.Response: + ) -> JobResponse: """ - Make a request to POST a document for enqueue on the V2 API. + Send a file to the asynchronous processing queue for inference processing. :param input_source: Input object. :param params: Options for the enqueueing of the document. - :param slug: Slug to use for the enqueueing, defaults to 'inferences'. :return: httpx response. """ data = params.get_request_parameters() - url = f"{self.url_root}/v2/{slug}/enqueue" + slug = params.get_enqueue_slug() + url = f"{self.url_root}/v2/products/{slug}/enqueue" post_kwargs: StringDict = {} if isinstance(input_source, LocalInputSource): post_kwargs["files"] = { @@ -116,82 +116,117 @@ def req_post_inference_enqueue( post_caller: Callable if self.http_client is None or self.http_client.is_closed: post_caller = httpx.post - post_kwargs["timeout"] = self.request_timeout else: post_caller = self.http_client.post - return post_caller( + + response = post_caller( url, headers=self.base_headers, data=data, + timeout=self.request_timeout, **post_kwargs, ) + dict_response = self._response_json(response) - def req_get_job(self, job_id: str) -> httpx.Response: + if not is_valid_sync_response(response): + handle_error_v2(dict_response) + return JobResponse(dict_response) + + def req_get_job_by_id(self, job_id: str) -> JobResponse: """ - Sends a request matching a given queue_id. Returns either a Job or a Document. + Get the result of an inference that was previously enqueued. :param job_id: Job ID, returned by the enqueue request. """ get_caller: Callable - get_kwargs: StringDict = {} if self.http_client is None or self.http_client.is_closed: get_caller = httpx.get - get_kwargs["timeout"] = self.request_timeout else: get_caller = self.http_client.get - return get_caller( + + response = get_caller( url=f"{self.url_root}/v2/jobs/{job_id}", headers=self.base_headers, follow_redirects=False, - **get_kwargs, + timeout=self.request_timeout, ) + dict_response = self._response_json(response) + if not is_valid_sync_response(response): + handle_error_v2(dict_response) + return JobResponse(dict_response) - def req_get_inference_by_url(self, url: str) -> httpx.Response: + def req_get_product_result_by_url( + self, response_type: type[ResponseT], url: str + ) -> ResponseT: """ - Sends a request matching a given inference_id. Returns either a Job or a - Document. + Get the result of an inference that was previously enqueued. :param url: URL to use for the request. + :param response_type: Type of the response to return. :return: Response object from the request. """ get_caller: Callable - get_kwargs: StringDict = {} if self.http_client is None or self.http_client.is_closed: get_caller = httpx.get - get_kwargs["timeout"] = self.request_timeout else: get_caller = self.http_client.get - return get_caller( + + response = get_caller( url=url, headers=self.base_headers, follow_redirects=False, - **get_kwargs, + timeout=self.request_timeout, ) + dict_response = self._response_json(response) + if not is_valid_sync_response(response): + handle_error_v2(dict_response) + return response_type(dict_response) - def req_get_inference(self, inference_id: str, slug: str) -> httpx.Response: + def req_get_product_result_by_id( + self, response_type: type[ResponseT], inference_id: str + ) -> ResponseT: """ Sends a request matching a given queue_id. Returns either a Job or a Document. :param inference_id: Inference ID, returned by the job request. - :param slug: Slug of the inference, defaults to nothing. + :param response_type: Type of the response to return. + """ + slug = response_type.get_result_slug() + return self.req_get_product_result_by_url( + response_type=response_type, + url=f"{self.url_root}/v2/products/{slug}/results/{inference_id}", + ) + + def req_search( + self, params: BaseSearchParameters[TypeSearchResponse] + ) -> TypeSearchResponse: + """ + Search for resources matching the given criteria. + :param params: Search parameters + :return: A search response containing the matching resources """ get_caller: Callable - get_kwargs: StringDict = {} if self.http_client is None or self.http_client.is_closed: get_caller = httpx.get - get_kwargs["timeout"] = self.request_timeout else: get_caller = self.http_client.get - return get_caller( - url=f"{self.url_root}/v2/{slug}/{inference_id}", + slug = params.get_slug() + response_class = params.get_response_class() + response = get_caller( + url=f"{self.url_root}/v2/search/{slug}", headers=self.base_headers, + params=params.get_request_parameters(), follow_redirects=False, - **get_kwargs, + timeout=self.request_timeout, ) + dict_response = self._response_json(response) + if not is_valid_sync_response(response): + handle_error_v2(dict_response) + return response_class(dict_response) def req_get_search_models( self, name: str | None, model_type: str | None - ) -> httpx.Response: + ) -> SearchResponse: """ Searches for a list of models matching criteria. :param name: Name pattern to search for. @@ -199,10 +234,8 @@ def req_get_search_models( :return: Response object containing search results. """ get_caller: Callable - get_kwargs: StringDict = {} if self.http_client is None or self.http_client.is_closed: get_caller = httpx.get - get_kwargs["timeout"] = self.request_timeout else: get_caller = self.http_client.get params = {} @@ -210,89 +243,16 @@ def req_get_search_models( params["name"] = name if model_type: params["model_type"] = model_type - return get_caller( + + response = get_caller( url=f"{self.url_root}/v2/search/models", headers=self.base_headers, params=params, follow_redirects=False, - **get_kwargs, - ) - - def enqueue( - self, - input_source: LocalInputSource | URLInputSource, - params: BaseProductParameters, - ) -> JobResponse: - """ - Enqueues a document to a given model. - :param input_source: Input object. - :param params: Parameters - :return: A valid inference Response. - """ - response = self.req_post_inference_enqueue( - input_source=input_source, params=params, slug=params.get_enqueue_slug() + timeout=self.request_timeout, ) dict_response = self._response_json(response) - - if not is_valid_post_response(response): - handle_error_v2(dict_response) - return JobResponse(dict_response) - - def get_job(self, job_id: str) -> JobResponse: - """ - Get the status of an inference that was previously enqueued. - - Can be used for polling. - - :param job_id: UUID of the job to retrieve. - :return: A job response. - """ - response = self.req_get_job(job_id) - dict_response = self._response_json(response) - if not is_valid_get_response(response): - handle_error_v2(dict_response) - return JobResponse(dict_response) - - def get_result(self, response_type: type[ResponseT], inference_id: str): - """ - Get the result of an inference that was previously enqueued. - - :param response_type: Type of the response to return. - :param inference_id: UUID of the inference to retrieve. - :return: The result of the inference. - """ - response = self.req_get_inference(inference_id, response_type.get_result_slug()) - dict_response = self._response_json(response) - if not is_valid_get_response(response): - handle_error_v2(dict_response) - return response_type(dict_response) - - def get_result_by_url(self, response_type: type[ResponseT], url: str): - """ - Get the result of an inference that was previously enqueued by its URL. - - :param response_type: Type of the response to return. - :param url: URL of the inference to retrieve. - :return: The result of the inference. - """ - response = self.req_get_inference_by_url(url) - dict_response = self._response_json(response) - if not is_valid_get_response(response): - handle_error_v2(dict_response) - return response_type(dict_response) - - def get_models(self, name: str | None, model_type: str | None): - """ - Get a list of models matching the provided name and type. - - :param name: Name of the model to filter by. - :param model_type: Type of the model to filter by. - :return: A list of models matching the provided criteria. - """ - logger.debug("Fetching models matching: name=%s and type=%s", name, model_type) - response = self.req_get_search_models(name, model_type) - dict_response = self._response_json(response) - if not is_valid_get_response(response): + if not is_valid_sync_response(response): handle_error_v2(dict_response) return SearchResponse(dict_response) diff --git a/mindee/v2/mindee_http/response_validation_v2.py b/mindee/v2/mindee_http/response_validation_v2.py deleted file mode 100644 index bb115bf1..00000000 --- a/mindee/v2/mindee_http/response_validation_v2.py +++ /dev/null @@ -1,35 +0,0 @@ -import httpx - -from mindee.mindee_http import is_valid_sync_response - - -def is_valid_post_response(response: httpx.Response) -> bool: - """ - Checks if the POST response is valid and of the expected format. - - :param response: HTTP response object. - :return: True if the response is valid. - """ - if not is_valid_sync_response(response): - return False - response_json = response.json() - if "job" not in response_json: - return False - return "job" in response_json and not response_json["job"].get("error") - - -def is_valid_get_response(response: httpx.Response) -> bool: - """ - Checks if the GET response is valid and of the expected format. - - :param response: HTTP response object. - :return: True if the response is valid. - """ - if not is_valid_sync_response(response): - return False - response_json = response.json() - return ( - "inference" in response_json - or "job" in response_json - or "models" in response_json - ) diff --git a/mindee/v2/parsing/inference/base_inference_response.py b/mindee/v2/parsing/inference/base_inference_response.py index ec38154c..1c46fc28 100644 --- a/mindee/v2/parsing/inference/base_inference_response.py +++ b/mindee/v2/parsing/inference/base_inference_response.py @@ -12,7 +12,7 @@ class BaseInferenceResponse(ABC, CommonResponse): """The inference result for a split utility request""" _slug: ClassVar[str] - """Slug of the inference.""" + """Slug of the product.""" def __str__(self) -> str: return str(self.inference) diff --git a/mindee/v2/parsing/search/__init__.py b/mindee/v2/parsing/search/__init__.py index 50de5d6d..41ba4f97 100644 --- a/mindee/v2/parsing/search/__init__.py +++ b/mindee/v2/parsing/search/__init__.py @@ -2,6 +2,8 @@ from mindee.v2.parsing.search.pagination_metadata import PaginationMetadata from mindee.v2.parsing.search.search_model import SearchModel from mindee.v2.parsing.search.search_models import SearchModels +from mindee.v2.parsing.search.search_rag_document import SearchRagDocument +from mindee.v2.parsing.search.search_rag_documents import SearchRagDocuments from mindee.v2.parsing.search.search_response import SearchResponse __all__ = [ @@ -9,5 +11,7 @@ "PaginationMetadata", "SearchModel", "SearchModels", + "SearchRagDocument", + "SearchRagDocuments", "SearchResponse", ] diff --git a/mindee/v2/parsing/search/base_search_response.py b/mindee/v2/parsing/search/base_search_response.py new file mode 100644 index 00000000..f0b49af9 --- /dev/null +++ b/mindee/v2/parsing/search/base_search_response.py @@ -0,0 +1,28 @@ +from abc import ABC, abstractmethod + +from mindee.parsing.common import StringDict +from mindee.parsing.common.common_response import CommonResponse +from mindee.v2.parsing.search.pagination_metadata import PaginationMetadata + + +class BaseSearchResponse(ABC, CommonResponse): + """Base class for search responses.""" + + pagination: PaginationMetadata + """Pagination metadata for the search results.""" + + def __init__(self, raw_response: StringDict) -> None: + super().__init__(raw_response) + self.pagination = PaginationMetadata(raw_response["pagination"]) + + @abstractmethod + def body_lines(self) -> list[str]: + """List of strings representing the search response.""" + + def __str__(self) -> str: + """ + String representation. + """ + lines: list[str] = self.body_lines() + lines += ["Pagination Metadata", "###################", str(self.pagination)] + return "\n".join(lines) diff --git a/mindee/v2/parsing/search/search_models.py b/mindee/v2/parsing/search/search_models.py index 6ca05542..c7c907d6 100644 --- a/mindee/v2/parsing/search/search_models.py +++ b/mindee/v2/parsing/search/search_models.py @@ -5,7 +5,7 @@ class SearchModels(list[SearchModel]): """List of models.""" def __init__(self, raw_response: list[dict]) -> None: - super().__init__([SearchModel(model) for model in raw_response]) + super().__init__([SearchModel(item) for item in raw_response]) def __str__(self) -> str: """ diff --git a/mindee/v2/parsing/search/search_rag_document.py b/mindee/v2/parsing/search/search_rag_document.py new file mode 100644 index 00000000..8f30eea9 --- /dev/null +++ b/mindee/v2/parsing/search/search_rag_document.py @@ -0,0 +1,39 @@ +from datetime import datetime + +from mindee.parsing.common.string_dict import StringDict + + +class SearchRagDocument: + """Individual RAG document information.""" + + id: str + """Unique identifier of the RAG document.""" + model_id: str + """Model identifier linked to the RAG document.""" + filename: str + """Original filename of the uploaded document.""" + created_at: datetime + """Date and time of the document creation.""" + total_matches: int + """Number of times this document was used in an inference.""" + last_match_at: datetime | None + """Date and time of the latest matching inference, if any.""" + status: str + """Current status of the RAG document.""" + + def __init__(self, server_response: StringDict) -> None: + self.id = server_response["id"] + self.model_id = server_response["model_id"] + self.filename = server_response["filename"] + self.created_at = datetime.fromisoformat( + server_response["created_at"].replace("Z", "+00:00") + ) + self.total_matches = server_response["total_matches"] + self.last_match_at = ( + datetime.fromisoformat( + server_response["last_match_at"].replace("Z", "+00:00") + ) + if server_response.get("last_match_at") + else None + ) + self.status = server_response["status"] diff --git a/mindee/v2/parsing/search/search_rag_documents.py b/mindee/v2/parsing/search/search_rag_documents.py new file mode 100644 index 00000000..04e140d2 --- /dev/null +++ b/mindee/v2/parsing/search/search_rag_documents.py @@ -0,0 +1,27 @@ +from mindee.v2.parsing.search.search_rag_document import SearchRagDocument + + +class SearchRagDocuments(list[SearchRagDocument]): + """List of RAG documents.""" + + def __init__(self, raw_response: list[dict]) -> None: + super().__init__([SearchRagDocument(item) for item in raw_response]) + + def __str__(self) -> str: + """ + Default string representation. + """ + if len(self) == 0: + return "\n" + + lines = [] + for rag_document in self: + lines.append(f"* :ID: {rag_document.id}") + lines.append(f" :Model ID: {rag_document.model_id}") + lines.append(f" :Filename: {rag_document.filename}") + lines.append(f" :Created At: {rag_document.created_at}") + lines.append(f" :Total Matches: {rag_document.total_matches}") + lines.append(f" :Last Match At: {rag_document.last_match_at}") + lines.append(f" :Status: {rag_document.status}") + + return "\n".join(lines) + "\n" diff --git a/mindee/v2/parsing/search/search_response.py b/mindee/v2/parsing/search/search_response.py index 93a0facc..cba1baec 100644 --- a/mindee/v2/parsing/search/search_response.py +++ b/mindee/v2/parsing/search/search_response.py @@ -1,34 +1,5 @@ -from mindee.parsing.common.common_response import CommonResponse -from mindee.parsing.common.string_dict import StringDict -from mindee.v2.parsing.search.pagination_metadata import PaginationMetadata -from mindee.v2.parsing.search.search_models import SearchModels +from mindee.v2.search.models.model_search_response import ModelSearchResponse -class SearchResponse(CommonResponse): - """Models search response.""" - - models: SearchModels - """Parsed search payload.""" - pagination: PaginationMetadata - """Pagination metadata for the search results.""" - - def __init__(self, raw_response: StringDict) -> None: - super().__init__(raw_response) - self.models = SearchModels(raw_response["models"]) - self.pagination = PaginationMetadata(raw_response["pagination"]) - - def __str__(self) -> str: - """ - String representation. - """ - return "\n".join( - [ - "Models", - "######", - str(self.models), - "Pagination Metadata", - "###################", - str(self.pagination), - "", - ] - ) +class SearchResponse(ModelSearchResponse): + """Deprecated: use `ModelSearchResponse` instead.""" diff --git a/mindee/v2/product/classification/classification_response.py b/mindee/v2/product/classification/classification_response.py index 674dc98d..6a442a30 100644 --- a/mindee/v2/product/classification/classification_response.py +++ b/mindee/v2/product/classification/classification_response.py @@ -13,8 +13,7 @@ class ClassificationResponse(BaseInferenceResponse): inference: ClassificationInference """Inference object for classification inference.""" - _slug: ClassVar[str] = "products/classification/results" - """Slug of the inference.""" + _slug: ClassVar[str] = "classification" def __init__(self, raw_response: StringDict) -> None: super().__init__(raw_response) diff --git a/mindee/v2/product/classification/params/classification_parameters.py b/mindee/v2/product/classification/params/classification_parameters.py index 17046445..d6405371 100644 --- a/mindee/v2/product/classification/params/classification_parameters.py +++ b/mindee/v2/product/classification/params/classification_parameters.py @@ -4,8 +4,6 @@ class ClassificationParameters(BaseProductParameters): - """ - Parameters accepted by the classification utility v2 endpoint. - """ + """Parameters for sending a file to a Classification product.""" - _slug: ClassVar[str] = "products/classification" + _slug: ClassVar[str] = "classification" diff --git a/mindee/v2/product/crop/crop_response.py b/mindee/v2/product/crop/crop_response.py index e10ee1be..319c647d 100644 --- a/mindee/v2/product/crop/crop_response.py +++ b/mindee/v2/product/crop/crop_response.py @@ -11,8 +11,7 @@ class CropResponse(BaseInferenceResponse): inference: CropInference """Inference object for crop inference.""" - _slug: ClassVar[str] = "products/crop/results" - """Slug of the inference.""" + _slug: ClassVar[str] = "crop" def __init__(self, raw_response: StringDict) -> None: super().__init__(raw_response) diff --git a/mindee/v2/product/crop/params/crop_parameters.py b/mindee/v2/product/crop/params/crop_parameters.py index 6210105f..a9e2df54 100644 --- a/mindee/v2/product/crop/params/crop_parameters.py +++ b/mindee/v2/product/crop/params/crop_parameters.py @@ -4,8 +4,6 @@ class CropParameters(BaseProductParameters): - """ - Parameters accepted by the crop utility v2 endpoint. - """ + """Parameters for sending a file to a Crop product.""" - _slug: ClassVar[str] = "products/crop" + _slug: ClassVar[str] = "crop" diff --git a/mindee/v2/product/extraction/extraction_response.py b/mindee/v2/product/extraction/extraction_response.py index 103b85ce..2bb744ce 100644 --- a/mindee/v2/product/extraction/extraction_response.py +++ b/mindee/v2/product/extraction/extraction_response.py @@ -10,8 +10,7 @@ class ExtractionResponse(BaseInferenceResponse): inference: ExtractionInference """Inference result.""" - _slug: ClassVar[str] = "products/extraction/results" - """Slug of the inference.""" + _slug: ClassVar[str] = "extraction" def __init__(self, raw_response: StringDict) -> None: super().__init__(raw_response) diff --git a/mindee/v2/product/extraction/params/extraction_parameters.py b/mindee/v2/product/extraction/params/extraction_parameters.py index 2ad5f3ec..7940a890 100644 --- a/mindee/v2/product/extraction/params/extraction_parameters.py +++ b/mindee/v2/product/extraction/params/extraction_parameters.py @@ -8,7 +8,7 @@ @dataclass class ExtractionParameters(BaseProductParameters): - """Inference parameters to set when sending a file.""" + """Parameters for sending a file to an Extraction product.""" rag: bool | None = None """Enhance extraction accuracy with Retrieval-Augmented Generation.""" @@ -32,7 +32,7 @@ class ExtractionParameters(BaseProductParameters): Not recommended, for specific use only. """ - _slug: ClassVar[str] = "inferences" + _slug: ClassVar[str] = "extraction" """Slug of the endpoint.""" def __post_init__(self): diff --git a/mindee/v2/product/ocr/ocr_response.py b/mindee/v2/product/ocr/ocr_response.py index a5c40c53..fa1007b3 100644 --- a/mindee/v2/product/ocr/ocr_response.py +++ b/mindee/v2/product/ocr/ocr_response.py @@ -11,8 +11,7 @@ class OCRResponse(BaseInferenceResponse): inference: OCRInference """Inference object for ocr inference.""" - _slug: ClassVar[str] = "products/ocr/results" - """Slug of the inference.""" + _slug: ClassVar[str] = "ocr" def __init__(self, raw_response: StringDict) -> None: super().__init__(raw_response) diff --git a/mindee/v2/product/ocr/params/ocr_parameters.py b/mindee/v2/product/ocr/params/ocr_parameters.py index d52f711e..2a1b53a8 100644 --- a/mindee/v2/product/ocr/params/ocr_parameters.py +++ b/mindee/v2/product/ocr/params/ocr_parameters.py @@ -4,8 +4,6 @@ class OCRParameters(BaseProductParameters): - """ - Parameters accepted by the ocr utility v2 endpoint. - """ + """Parameters for sending a file to a Raw Text (OCR) product.""" - _slug: ClassVar[str] = "products/ocr" + _slug: ClassVar[str] = "ocr" diff --git a/mindee/v2/product/split/params/split_parameters.py b/mindee/v2/product/split/params/split_parameters.py index cfa2f98e..ef68b315 100644 --- a/mindee/v2/product/split/params/split_parameters.py +++ b/mindee/v2/product/split/params/split_parameters.py @@ -8,4 +8,4 @@ class SplitParameters(BaseProductParameters): Parameters accepted by the split utility v2 endpoint. """ - _slug: ClassVar[str] = "products/split" + _slug: ClassVar[str] = "split" diff --git a/mindee/v2/search/__init__.py b/mindee/v2/search/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mindee/v2/search/models/__init__.py b/mindee/v2/search/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mindee/v2/search/models/model_search_parameters.py b/mindee/v2/search/models/model_search_parameters.py new file mode 100644 index 00000000..063a6f68 --- /dev/null +++ b/mindee/v2/search/models/model_search_parameters.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from typing import ClassVar + +from mindee.v2.client_options.base_search_parameters import BaseSearchParameters +from mindee.v2.search.models.model_search_response import ModelSearchResponse + + +@dataclass(kw_only=True) +class ModelSearchParameters(BaseSearchParameters[ModelSearchResponse]): + """Search parameters for models.""" + + name: str | None = None + """Case-insensitive search term for the model name""" + + model_type: str | None = None + """Case-insensitive search term for the model type""" + + _slug: ClassVar[str] = "models" + _response_class: type[ModelSearchResponse] = ModelSearchResponse + + def get_request_parameters(self) -> dict[str, str | list[str]]: + """Return the parameters for the request.""" + + params = super().get_request_parameters() + + if self.name is not None: + params["name"] = self.name + if self.model_type is not None: + params["model_type"] = self.model_type + + return params diff --git a/mindee/v2/search/models/model_search_response.py b/mindee/v2/search/models/model_search_response.py new file mode 100644 index 00000000..549adbf2 --- /dev/null +++ b/mindee/v2/search/models/model_search_response.py @@ -0,0 +1,18 @@ +from mindee.parsing.common.string_dict import StringDict +from mindee.v2.parsing.search.base_search_response import BaseSearchResponse +from mindee.v2.parsing.search.search_models import SearchModels + + +class ModelSearchResponse(BaseSearchResponse): + """Models search response.""" + + models: SearchModels + """Paginated list of matching models.""" + + def __init__(self, raw_response: StringDict) -> None: + super().__init__(raw_response) + self.models = SearchModels(raw_response["models"]) + + def body_lines(self) -> list[str]: + """List of strings representing the search response.""" + return ["Models", "######", str(self.models)] diff --git a/mindee/v2/search/rag_documents/__init__.py b/mindee/v2/search/rag_documents/__init__.py new file mode 100644 index 00000000..5e923960 --- /dev/null +++ b/mindee/v2/search/rag_documents/__init__.py @@ -0,0 +1,11 @@ +from mindee.v2.search.rag_documents.rag_document_search_parameters import ( + RagDocumentSearchParameters, +) +from mindee.v2.search.rag_documents.rag_document_search_response import ( + RagDocumentSearchResponse, +) + +__all__ = [ + "RagDocumentSearchParameters", + "RagDocumentSearchResponse", +] diff --git a/mindee/v2/search/rag_documents/rag_document_search_parameters.py b/mindee/v2/search/rag_documents/rag_document_search_parameters.py new file mode 100644 index 00000000..02ace8a7 --- /dev/null +++ b/mindee/v2/search/rag_documents/rag_document_search_parameters.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from typing import ClassVar + +from mindee.v2.client_options.base_search_parameters import BaseSearchParameters +from mindee.v2.search.rag_documents.rag_document_search_response import ( + RagDocumentSearchResponse, +) + + +@dataclass(kw_only=True) +class RagDocumentSearchParameters(BaseSearchParameters[RagDocumentSearchResponse]): + """Search parameters for RAG Documents.""" + + model_id: str + """Model identifier to search in.""" + + filename: str | None = None + """Case-insensitive substring search on filename.""" + + _slug: ClassVar[str] = "rag-documents" + _response_class: type[RagDocumentSearchResponse] = RagDocumentSearchResponse + + def get_request_parameters(self) -> dict[str, str | list[str]]: + params = super().get_request_parameters() + + params["model_id"] = self.model_id + + if self.filename is not None: + params["filename"] = self.filename + + return params diff --git a/mindee/v2/search/rag_documents/rag_document_search_response.py b/mindee/v2/search/rag_documents/rag_document_search_response.py new file mode 100644 index 00000000..77e48c09 --- /dev/null +++ b/mindee/v2/search/rag_documents/rag_document_search_response.py @@ -0,0 +1,18 @@ +from mindee.parsing.common.string_dict import StringDict +from mindee.v2.parsing.search.base_search_response import BaseSearchResponse +from mindee.v2.parsing.search.search_rag_documents import SearchRagDocuments + + +class RagDocumentSearchResponse(BaseSearchResponse): + """RAG documents search response.""" + + rag_documents: SearchRagDocuments + """Paginated list of matching RAG documents.""" + + def __init__(self, raw_response: StringDict) -> None: + super().__init__(raw_response) + self.rag_documents = SearchRagDocuments(raw_response["rag_documents"]) + + def body_lines(self) -> list[str]: + """List of strings representing the search response.""" + return ["RAG Documents", "################", str(self.rag_documents)] diff --git a/tests/data b/tests/data index e41ab97c..4b7f3376 160000 --- a/tests/data +++ b/tests/data @@ -1 +1 @@ -Subproject commit e41ab97c2833f15ea5c4edf221c2d197d212f632 +Subproject commit 4b7f33766fab0e67804b84447b73c80a902d886a diff --git a/tests/v2/search/test_model_search.py b/tests/v2/search/test_model_search.py new file mode 100644 index 00000000..9b08d4dd --- /dev/null +++ b/tests/v2/search/test_model_search.py @@ -0,0 +1,34 @@ +import pytest + +from mindee.input import LocalResponse +from mindee.v2.search.models.model_search_response import ModelSearchResponse +from tests.utils import V2_DATA_DIR + + +@pytest.mark.v2 +def test_should_load_search_models_locally(): + file_path = V2_DATA_DIR / "search" / "models.json" + local_response = LocalResponse(file_path) + response = local_response.deserialize_response(ModelSearchResponse) + + assert isinstance(response, ModelSearchResponse) + + assert len(response.models) == 5 + assert response.pagination.total_items == 5 + assert response.pagination.page == 1 + assert response.pagination.per_page == 50 + assert response.pagination.total_pages == 1 + + first_item = response.models[0] + assert first_item.name == "Extraction With Webhooks" + assert first_item.id == "afde5151-aa11-aa11-9289-fa04e50ca3b9" + assert first_item.model_type == "extraction" + + assert len(first_item.webhooks) == 2 + assert first_item.webhooks[0].id == "a2286ed9-aa11-aa11-bdc5-2f8496c5641a" + assert first_item.webhooks[0].name == "FAILURE" + assert first_item.webhooks[0].url == "https://failure.mindee.com" + + last_item = response.models[-1] + assert last_item.name == "Extraction Without Webhooks Key" + assert last_item.id == "e14e0923-ee55-ee55-a335-8d2110917d7b" diff --git a/tests/v2/search/test_model_search_integration.py b/tests/v2/search/test_model_search_integration.py new file mode 100644 index 00000000..4c2a7e4b --- /dev/null +++ b/tests/v2/search/test_model_search_integration.py @@ -0,0 +1,33 @@ +import pytest + +from mindee.v2.client import Client +from mindee.v2.search.models.model_search_parameters import ModelSearchParameters + + +@pytest.fixture(scope="session") +def v2_client() -> Client: + return Client() + + +@pytest.mark.integration +@pytest.mark.v2 +def test_must_have_results(v2_client: Client): + response = v2_client.search(ModelSearchParameters()) + + assert response is not None + assert len(response.models) > 0 + assert response.pagination is not None + assert response.pagination.total_items >= 1 + assert response.pagination.page == 1 + + +@pytest.mark.integration +@pytest.mark.v2 +def test_must_return_empty(v2_client: Client): + response = v2_client.search(ModelSearchParameters(name="je n'existe pas tralala")) + + assert response is not None + assert len(response.models) == 0 + assert response.pagination is not None + assert response.pagination.total_items == 0 + assert response.pagination.page == 1 diff --git a/tests/v2/search/test_rag_document_search.py b/tests/v2/search/test_rag_document_search.py new file mode 100644 index 00000000..5cadfb93 --- /dev/null +++ b/tests/v2/search/test_rag_document_search.py @@ -0,0 +1,59 @@ +from datetime import datetime, timezone + +import pytest + +from mindee.input import LocalResponse +from mindee.v2.search.rag_documents.rag_document_search_response import ( + RagDocumentSearchResponse, +) +from tests.utils import V2_DATA_DIR + + +@pytest.mark.v2 +def test_should_load_search_rag_documents_locally(): + file_path = V2_DATA_DIR / "search" / "rag_documents.json" + local_response = LocalResponse(file_path) + response = local_response.deserialize_response(RagDocumentSearchResponse) + + assert isinstance(response, RagDocumentSearchResponse) + + assert len(response.rag_documents) == 3 + assert response.pagination.total_items == 3 + assert response.pagination.page == 1 + assert response.pagination.per_page == 50 + assert response.pagination.total_pages == 1 + + first_item = response.rag_documents[0] + assert first_item.id == "cc831599-c545-48b7-aa27-6d7ccd5b8d32" + assert first_item.model_id == "12345678-1234-1234-1234-123456789abc" + assert first_item.filename == "invoice_01.pdf" + assert first_item.created_at == datetime( + 2026, 6, 30, 13, 13, 46, 168586, tzinfo=timezone.utc + ) + assert first_item.total_matches == 0 + assert first_item.last_match_at is None + assert first_item.status == "Processing" + + second_item = response.rag_documents[1] + assert second_item.id == "27467e4c-5602-4315-90d9-3d2da69b05ab" + assert second_item.model_id == "12345678-1234-1234-1234-123456789abc" + assert second_item.filename == "invoice_02.pdf" + assert second_item.created_at == datetime( + 2026, 6, 30, 13, 13, 46, 168586, tzinfo=timezone.utc + ) + assert second_item.total_matches == 0 + assert second_item.last_match_at is None + assert second_item.status == "Draft" + + third_item = response.rag_documents[2] + assert third_item.id == "a6bcae7d-0439-476b-8a63-5a39ec05dc21" + assert third_item.model_id == "12345678-1234-1234-1234-jobid1234567" + assert third_item.filename == "invoice_03.pdf" + assert third_item.created_at == datetime( + 2026, 6, 17, 14, 35, 46, 228006, tzinfo=timezone.utc + ) + assert third_item.total_matches == 5 + assert third_item.last_match_at == datetime( + 2026, 6, 18, 14, 35, 46, 248006, tzinfo=timezone.utc + ) + assert third_item.status == "Active" diff --git a/tests/v2/search/test_rag_document_search_integration.py b/tests/v2/search/test_rag_document_search_integration.py new file mode 100644 index 00000000..dd16df12 --- /dev/null +++ b/tests/v2/search/test_rag_document_search_integration.py @@ -0,0 +1,26 @@ +import os + +import pytest + +from mindee.v2.client import Client +from mindee.v2.search.rag_documents.rag_document_search_parameters import ( + RagDocumentSearchParameters, +) + + +@pytest.fixture(scope="session") +def v2_client() -> Client: + return Client() + + +@pytest.mark.integration +@pytest.mark.v2 +def test_must_have_results(v2_client: Client): + findoc_model_id = os.getenv("MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID", "") + response = v2_client.search(RagDocumentSearchParameters(model_id=findoc_model_id)) + + assert response is not None + assert len(response.rag_documents) > 0 + assert response.pagination is not None + assert response.pagination.total_items >= 1 + assert response.pagination.page == 1 diff --git a/tests/v2/search/test_search_models.py b/tests/v2/search/test_search_models.py deleted file mode 100644 index 2173f552..00000000 --- a/tests/v2/search/test_search_models.py +++ /dev/null @@ -1,33 +0,0 @@ -import json - -import pytest - -from mindee.v2.parsing.search.search_response import SearchResponse -from tests.utils import V2_DATA_DIR - - -@pytest.mark.v2 -def test_search_models(): - data_file = V2_DATA_DIR / "search" / "models.json" - json_file = data_file.read_text() - models_json = json.loads(json_file) - models_response = SearchResponse(models_json) - - assert len(models_response.models) == models_response.pagination.total_items == 5 - assert models_response.pagination.page == 1 - assert models_response.pagination.per_page == 50 - assert models_response.pagination.total_pages == 1 - assert models_response.pagination.total_items_unfiltered is None - - assert models_response.models[0].name == "Extraction With Webhooks" - assert models_response.models[0].id == "afde5151-aa11-aa11-9289-fa04e50ca3b9" - assert models_response.models[0].model_type == "extraction" - assert len(models_response.models[0].webhooks) == 2 - assert ( - models_response.models[0].webhooks[0].id - == "a2286ed9-aa11-aa11-bdc5-2f8496c5641a" - ) - assert models_response.models[0].webhooks[0].name == "FAILURE" - assert models_response.models[0].webhooks[0].url == "https://failure.mindee.com" - assert models_response.models[-1].name == "Extraction Without Webhooks Key" - assert models_response.models[-1].id == "e14e0923-ee55-ee55-a335-8d2110917d7b" diff --git a/tests/v2/test_client.py b/tests/v2/test_client.py index ad4b9de2..cf62b691 100644 --- a/tests/v2/test_client.py +++ b/tests/v2/test_client.py @@ -24,6 +24,8 @@ from mindee.v2.product.extraction.extraction_inference import ExtractionInference from tests.utils import FILE_TYPES_DIR, V2_DATA_DIR, V2_PRODUCT_DATA_DIR, dummy_envvars +# --- Fixtures & Helper Utilities --- + @pytest.fixture def env_client(monkeypatch) -> Client: @@ -32,92 +34,46 @@ def env_client(monkeypatch) -> Client: @pytest.fixture -def custom_base_url_client(monkeypatch) -> Client: - class _FakePostRespError: - status_code = 400 - is_error = True - - def json(self): - return { - "status": 0, - "code": "000-000", - "title": "From Test", - "detail": "forced failure from test", - } - - class _FakeOkProcessingJobResp: - status_code = 200 - is_error = False - - def json(self): - data_file = V2_DATA_DIR / "job" / "ok_processing.json" - with data_file.open("r", encoding="utf-8") as fh: - return json.load(fh) - - @property - def content(self) -> bytes: - """ - Raw (bytes) payload, mimicking `requests.Response.content`. - """ - return json.dumps(self.json()).encode("utf-8") - - class _FakeOkGetInferenceResp: - status_code = 200 - is_error = False - - def json(self): - data_file = ( - V2_PRODUCT_DATA_DIR - / "extraction" - / "financial_document" - / "complete.json" - ) - with data_file.open("r", encoding="utf-8") as fh: - return json.load(fh) - - @property - def content(self) -> bytes: - """ - Raw (bytes) payload, mimicking `requests.Response.content`. - """ - return json.dumps(self.json()).encode("utf-8") +def env_no_key(monkeypatch): + if os.getenv("MINDEE_V2_API_KEY"): + monkeypatch.delenv("MINDEE_V2_API_KEY") + +@pytest.fixture +def dummy_url_client(monkeypatch) -> Client: monkeypatch.setenv("MINDEE_V2_BASE_URL", "https://dummy-url") + return Client("dummy") - def _fake_error_post_inference_enqueue(*args, **kwargs): - return _FakePostRespError() - def _fake_ok_get_job(*args, **kwargs): - return _FakeOkProcessingJobResp() +@pytest.fixture +def findoc_json() -> dict: + data_file = ( + V2_PRODUCT_DATA_DIR / "extraction" / "financial_document" / "complete.json" + ) + return json.loads(data_file.read_text(encoding="utf-8")) - def _fake_ok_get_inference(*args, **kwargs): - return _FakeOkGetInferenceResp() - monkeypatch.setattr( - "mindee.v2.mindee_http.mindee_api_v2.MindeeAPIV2.req_post_inference_enqueue", - _fake_error_post_inference_enqueue, - raising=True, - ) +@pytest.fixture +def job_processing_json() -> dict: + data_file = V2_DATA_DIR / "job" / "ok_processing.json" + return json.loads(data_file.read_text(encoding="utf-8")) - monkeypatch.setattr( - "mindee.v2.mindee_http.mindee_api_v2.MindeeAPIV2.req_get_job", - _fake_ok_get_job, - raising=True, - ) - monkeypatch.setattr( - "mindee.v2.mindee_http.mindee_api_v2.MindeeAPIV2.req_get_inference", - _fake_ok_get_inference, - raising=True, - ) +@pytest.fixture +def job_fail_422_json() -> dict: + data_file = V2_DATA_DIR / "job" / "fail_422.json" + return json.loads(data_file.read_text(encoding="utf-8")) - return Client("dummy") + +def _assert_findoc_inference(response: ExtractionResponse): + assert isinstance(response, ExtractionResponse) + assert isinstance(response.inference, ExtractionInference) + assert response.inference.id + assert response.inference.model.id + assert len(response.inference.result.fields) > 1 -@pytest.fixture -def env_no_key(monkeypatch): - if os.getenv("MINDEE_V2_API_KEY"): - monkeypatch.delenv("MINDEE_V2_API_KEY") +# --- Tests --- @pytest.mark.v2 @@ -127,22 +83,35 @@ def test_parse_path_without_token(env_no_key): @pytest.mark.v2 -def test_enqueue_path_with_env_token(custom_base_url_client): - assert custom_base_url_client.mindee_api.base_url == "https://dummy-url" - assert custom_base_url_client.mindee_api.url_root == "https://dummy-url" - assert custom_base_url_client.mindee_api.api_key == "dummy" - assert custom_base_url_client.mindee_api.base_headers["Authorization"] == "dummy" - assert custom_base_url_client.mindee_api.base_headers["User-Agent"] == USER_AGENT +@respx.mock +def test_enqueue_path_with_env_token(dummy_url_client, job_fail_422_json): + respx.post(re.compile(r"https://dummy-url/.*")).respond( + status_code=422, + json=job_fail_422_json, + ) + + assert dummy_url_client.mindee_api.base_url == "https://dummy-url" + assert dummy_url_client.mindee_api.url_root == "https://dummy-url" + assert dummy_url_client.mindee_api.api_key == "dummy" + assert dummy_url_client.mindee_api.base_headers["Authorization"] == "dummy" + assert dummy_url_client.mindee_api.base_headers["User-Agent"] == USER_AGENT + input_doc: LocalInputSource = PathInput(f"{FILE_TYPES_DIR}/receipt.jpg") with pytest.raises(MindeeHTTPErrorV2): - custom_base_url_client.enqueue(input_doc, ExtractionParameters("dummy-model")) + dummy_url_client.enqueue(input_doc, ExtractionParameters("dummy-model")) @pytest.mark.v2 -def test_enqueue_and_parse_path_with_env_token(custom_base_url_client): +@respx.mock +def test_enqueue_and_parse_path_with_env_token(dummy_url_client, job_fail_422_json): + respx.post(re.compile(r"https://dummy-url/.*")).respond( + status_code=422, + json=job_fail_422_json, + ) + input_doc: LocalInputSource = PathInput(f"{FILE_TYPES_DIR}/receipt.jpg") with pytest.raises(MindeeHTTPErrorV2): - custom_base_url_client.enqueue_and_get_result( + dummy_url_client.enqueue_and_get_result( ExtractionResponse, input_doc, ExtractionParameters( @@ -159,14 +128,6 @@ def test_enqueue_and_parse_path_with_env_token(custom_base_url_client): ) -def _assert_findoc_inference(response: ExtractionResponse): - assert isinstance(response, ExtractionResponse) - assert isinstance(response.inference, ExtractionInference) - assert response.inference.id - assert response.inference.model.id - assert len(response.inference.result.fields) > 1 - - @pytest.mark.v2 def test_loads_from_prediction(): input_inference = LocalResponse( @@ -179,26 +140,51 @@ def test_loads_from_prediction(): @pytest.mark.v2 -def test_get_inference(custom_base_url_client): - response = custom_base_url_client.get_result( +@respx.mock +def test_get_inference_by_id(dummy_url_client, findoc_json): + respx.get( + re.compile(r"https://dummy-url/v2/products/extraction/results/.*") + ).respond( + status_code=200, + json=findoc_json, + ) + response = dummy_url_client.get_result( ExtractionResponse, "12345678-1234-1234-1234-123456789ABC" ) _assert_findoc_inference(response) @pytest.mark.v2 -def test_get_inference_by_url(custom_base_url_client): - response = custom_base_url_client.get_result( +@respx.mock +def test_get_inference_by_url(dummy_url_client, findoc_json): + respx.get( + "https://api-v2.mindee.net/v2/products/extraction/results/12345678-1234-1234-1234-123456789ABC" + ).respond( + status_code=200, + json=findoc_json, + ) + response = dummy_url_client.get_result_from_url( ExtractionResponse, - "https://api-v2.mindee.net/v2/inference/12345678-1234-1234-1234-123456789ABC", + "https://api-v2.mindee.net/v2/products/extraction/results/12345678-1234-1234-1234-123456789ABC", ) _assert_findoc_inference(response) @pytest.mark.v2 -def test_error_handling(custom_base_url_client): +@respx.mock +def test_error_handling(dummy_url_client): + respx.post(re.compile(r"https://dummy-url/.*")).respond( + status_code=400, + json={ + "status": 0, + "code": "000-000", + "title": "From Test", + "detail": "forced failure from test", + }, + ) + with pytest.raises(MindeeHTTPErrorV2) as e: - custom_base_url_client.enqueue( + dummy_url_client.enqueue( PathInput( V2_PRODUCT_DATA_DIR / "extraction" @@ -212,22 +198,11 @@ def test_error_handling(custom_base_url_client): @pytest.mark.v2 -def test_error_handling_non_json_response(env_client, monkeypatch): - class _FakeHtmlRespError: - status_code = 502 - is_error = True - text = "502 Bad Gateway" - - def json(self): - raise httpx.DecodingError("Expecting value") - - def _fake_error_post_inference_enqueue(*args, **kwargs): - return _FakeHtmlRespError() - - monkeypatch.setattr( - "mindee.v2.mindee_http.mindee_api_v2.MindeeAPIV2.req_post_inference_enqueue", - _fake_error_post_inference_enqueue, - raising=True, +@respx.mock +def test_error_handling_non_json_response(env_client): + respx.post(re.compile(r"https://api-v2\.mindee\.net/.*")).respond( + status_code=502, + text="502 Bad Gateway", ) with pytest.raises(MindeeHTTPUnknownErrorV2) as e: @@ -245,8 +220,13 @@ def _fake_error_post_inference_enqueue(*args, **kwargs): @pytest.mark.v2 -def test_queue_get(custom_base_url_client): - response = custom_base_url_client.get_job("12345678-1234-1234-1234-123456789ABC") +@respx.mock +def test_get_job_by_id(dummy_url_client, job_processing_json): + respx.get(re.compile(r"https://dummy-url/v2/jobs/.*")).respond( + status_code=200, json=job_processing_json + ) + + response = dummy_url_client.get_job("12345678-1234-1234-1234-123456789ABC") assert isinstance(response, JobResponse) assert isinstance(response.job, Job) assert response.job.id == "12345678-1234-1234-1234-123456789ABC" @@ -269,7 +249,7 @@ def test_client_closes_httpx_connections() -> None: client = Client(api_key="dummy_key") client.close() with pytest.raises( - AttributeError, match=r"NoneType' object has no attribute 'get'" + AttributeError, match=r"'NoneType' object has no attribute 'get'" ): client.mindee_api.http_client.get("https://google.com") @@ -312,7 +292,7 @@ def make_request(): @pytest.mark.v2 @respx.mock def test_explicit_timeout_failure(findoc_model_id) -> None: - respx.post("https://api-v2.mindee.net/v2/inferences/enqueue").mock( + respx.post("https://api-v2.mindee.net/v2/products/extraction/enqueue").mock( side_effect=httpx.ReadTimeout("Simulated Read Timeout") ) @@ -328,7 +308,10 @@ def test_explicit_timeout_failure(findoc_model_id) -> None: @respx.mock def test_explicit_500_server_error(findoc_model_id: str) -> None: respx.post(re.compile(r"https://api-v2\.mindee\.net/v2/.+/enqueue")).mock( - return_value=httpx.Response(500, json={"message": "Internal Server Error"}) + return_value=httpx.Response( + 500, + json={"message": "Internal Server Error"}, + ) ) client = Client(api_key="dummy") @@ -338,3 +321,24 @@ def test_explicit_500_server_error(findoc_model_id: str) -> None: client.enqueue(input_source, params) assert "Couldn't deserialize server error" in str(exc_info.value) + + +@pytest.mark.v2 +def test_client_accepts_custom_http_client(job_processing_json): + def mock_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json=job_processing_json, + ) + + # 1. Create a custom injected transport + custom_http_client = httpx.Client( + transport=httpx.MockTransport(mock_handler), base_url="https://dummy-url" + ) + + # 2. Pass it directly to the Mindee Client + client = Client(api_key="dummy", http_client=custom_http_client) + + # 3. Assert the injected transport handled the call + response = client.get_job("12345678-1234-1234-1234-123456789ABC") + assert response.job.status == "Processing" From f0fad665c5acbd97c37dc2301256f8d6bca2635d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ianar=C3=A9?= <97107275+ianardee@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:55:15 +0200 Subject: [PATCH 2/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/v2/search/test_rag_document_search_integration.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/v2/search/test_rag_document_search_integration.py b/tests/v2/search/test_rag_document_search_integration.py index dd16df12..5783527c 100644 --- a/tests/v2/search/test_rag_document_search_integration.py +++ b/tests/v2/search/test_rag_document_search_integration.py @@ -16,7 +16,8 @@ def v2_client() -> Client: @pytest.mark.integration @pytest.mark.v2 def test_must_have_results(v2_client: Client): - findoc_model_id = os.getenv("MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID", "") + findoc_model_id = os.getenv("MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID") + assert findoc_model_id, "MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID must be set" response = v2_client.search(RagDocumentSearchParameters(model_id=findoc_model_id)) assert response is not None From 8365779e8c419ba226a79cb42606c8b22a1a5f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ianar=C3=A9?= <97107275+ianardee@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:55:38 +0200 Subject: [PATCH 3/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- mindee/v2/mindee_http/mindee_api_v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mindee/v2/mindee_http/mindee_api_v2.py b/mindee/v2/mindee_http/mindee_api_v2.py index af122428..19defe44 100644 --- a/mindee/v2/mindee_http/mindee_api_v2.py +++ b/mindee/v2/mindee_http/mindee_api_v2.py @@ -100,7 +100,7 @@ def req_post_product_enqueue( :param input_source: Input object. :param params: Options for the enqueueing of the document. - :return: httpx response. + :return: A `JobResponse` containing the enqueued job. """ data = params.get_request_parameters() slug = params.get_enqueue_slug() From adda09558651df6ad291d6265dd9e1606c3973c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ianar=C3=A9?= <97107275+ianardee@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:57:46 +0200 Subject: [PATCH 4/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- mindee/v2/parsing/search/base_search_response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mindee/v2/parsing/search/base_search_response.py b/mindee/v2/parsing/search/base_search_response.py index f0b49af9..3d0eb1f5 100644 --- a/mindee/v2/parsing/search/base_search_response.py +++ b/mindee/v2/parsing/search/base_search_response.py @@ -5,7 +5,7 @@ from mindee.v2.parsing.search.pagination_metadata import PaginationMetadata -class BaseSearchResponse(ABC, CommonResponse): +class BaseSearchResponse(CommonResponse, ABC): """Base class for search responses.""" pagination: PaginationMetadata From 8f2de52c33985a0ebfeb50987fa97b78d58efac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ianar=C3=A9=20S=C3=A9vi?= Date: Thu, 20 Aug 2026 18:02:20 +0200 Subject: [PATCH 5/5] :sparkles: add RAG search API --- mindee/v2/client.py | 6 +----- mindee/v2/mindee_http/mindee_api_v2.py | 5 +---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/mindee/v2/client.py b/mindee/v2/client.py index 83007baf..3608eb91 100644 --- a/mindee/v2/client.py +++ b/mindee/v2/client.py @@ -187,11 +187,7 @@ def search_models( self, name: str | None = None, model_type: str | None = None ) -> SearchResponse: """ - Get a list of models matching the provided name and type. - - :param name: Name of the model to filter by. - :param model_type: Type of the model to filter by. - :return: A list of models matching the provided criteria. + Deprecated. Use `search` instead. """ return self.mindee_api.req_get_search_models(name, model_type) diff --git a/mindee/v2/mindee_http/mindee_api_v2.py b/mindee/v2/mindee_http/mindee_api_v2.py index 19defe44..3378f65e 100644 --- a/mindee/v2/mindee_http/mindee_api_v2.py +++ b/mindee/v2/mindee_http/mindee_api_v2.py @@ -228,10 +228,7 @@ def req_get_search_models( self, name: str | None, model_type: str | None ) -> SearchResponse: """ - Searches for a list of models matching criteria. - :param name: Name pattern to search for. - :param model_type: Type of model to search for (exact match). - :return: Response object containing search results. + Deprecated. Use `search` instead. """ get_caller: Callable if self.http_client is None or self.http_client.is_closed: