diff --git a/src/V2/Client.php b/src/V2/Client.php index ac2a8b1d..d62e35b7 100644 --- a/src/V2/Client.php +++ b/src/V2/Client.php @@ -9,13 +9,18 @@ use Mindee\Error\MindeeException; use Mindee\Http\CancellationToken; use Mindee\Input\InputSource; +use Mindee\Input\LocalInputSource; +use Mindee\V2\ClientOptions\BaseAnnotationParameters; use Mindee\V2\ClientOptions\BaseProductParameters; +use Mindee\V2\ClientOptions\BaseSearchParameters; use Mindee\V2\Http\MindeeApiV2; +use Mindee\V2\Parsing\BaseRagAnnotationResponse; use Mindee\V2\Parsing\Inference\BaseResponse; use Mindee\V2\Parsing\Job\JobResponse; -use Mindee\V2\ClientOptions\BaseSearchParameters; use Mindee\V2\Parsing\Search\BaseSearchResponse; use Mindee\V2\Parsing\Search\ModelSearchResponse; +use Mindee\V2\Product\Extraction\RagDocuments\ExtractionRagAnnotationResponse; +use Mindee\V2\Product\Extraction\RagDocuments\Params\RagDocumentUploadParameters; use Mindee\V2\Search\Models\ModelSearchParameters; /** @@ -81,7 +86,7 @@ public function getResult( string $responseClass, string $resultId ): BaseResponse { - return $this->mindeeApi->reqGetResult($responseClass, $resultId); + return $this->mindeeApi->reqGetResultById($responseClass, $resultId); } /** @@ -94,7 +99,20 @@ public function getResult( */ public function getJob(string $jobId): JobResponse { - return $this->mindeeApi->reqGetJob($jobId); + return $this->mindeeApi->reqGetJobById($jobId); + } + + /** + * Get the status of a job from its polling URL. + * Can be used for polling. + * + * @param string $pollingUrl URL to poll to retrieve the job. + * @return JobResponse A JobResponse containing a Job. + * @category Asynchronous + */ + public function getJobFromUrl(string $pollingUrl): JobResponse + { + return $this->mindeeApi->reqGetJobFromUrl($pollingUrl); } /** @@ -130,11 +148,12 @@ public function enqueueAndGetResult( } $jobId = $enqueueResponse->job->id; + $pollingUrl = $enqueueResponse->job->pollingUrl; error_log("Successfully enqueued document with job ID: " . $jobId); $this->customSleep($pollingOptions->initialDelaySec, $cancellationToken); $retryCounter = 1; - $pollResults = $this->getJob($jobId); + $pollResults = $this->getJobFromUrl($pollingUrl); while ($retryCounter < $pollingOptions->maxRetries) { if ($pollResults->job->status === "Failed") { @@ -151,7 +170,7 @@ public function enqueueAndGetResult( ); $this->customSleep($pollingOptions->delaySec, $cancellationToken); - $pollResults = $this->getJob($jobId); + $pollResults = $this->getJobFromUrl($pollingUrl); $retryCounter++; } @@ -167,6 +186,71 @@ public function enqueueAndGetResult( ); } + /** + * Not recommended for general use, prefer uploadAndGetRagDocumentPoll(). + * You will need to poll until the document is ready for use. + * Add a document to the RAG database. + * + * @template T of BaseRagAnnotationResponse + * @param string $responseClass The response class to construct. + * @phpstan-param class-string $responseClass + * @param LocalInputSource $inputSource Local file to upload. + * @param RagDocumentUploadParameters $params Upload parameters. + * @return T + */ + public function uploadRagDocument( + string $responseClass, + LocalInputSource $inputSource, + RagDocumentUploadParameters $params + ): BaseRagAnnotationResponse { + error_log("Adding a document to the RAG database"); + return $this->mindeeApi->reqPostRagDocument($responseClass, $inputSource, $params); + } + + /** + * Not recommended for general use, prefer getReadyRagDocumentPoll(). + * You will need to poll until the document is ready for use. + * Get a document's info and annotations from the RAG database. + * + * @template T of BaseRagAnnotationResponse + * @param string $responseClass The response class to construct. + * @phpstan-param class-string $responseClass + * @param string $documentId Unique identifier of the RAG document. + * @return T + */ + public function getRagDocument(string $responseClass, string $documentId): BaseRagAnnotationResponse + { + return $this->mindeeApi->reqGetRagAnnotation($responseClass, $documentId); + } + + /** + * Update a document's annotations in the RAG database. + * + * @template T of BaseRagAnnotationResponse + * @param string $responseClass The response class to construct. + * @phpstan-param class-string $responseClass + * @param BaseAnnotationParameters $params Annotation parameters including the document ID and fields to update. + * @return T + */ + public function updateRagAnnotation( + string $responseClass, + BaseAnnotationParameters $params + ): BaseRagAnnotationResponse { + return $this->mindeeApi->reqPatchRagAnnotation($responseClass, $params); + } + + /** + * Delete a document from the RAG database. + * For extraction models only. + * + * @param string $documentId Unique identifier of the RAG document to delete. + * @return bool True if the deletion was successful, false otherwise. + */ + public function deleteExtractionRagDocument(string $documentId): bool + { + return $this->mindeeApi->reqDeleteExtractionRagDocument($documentId); + } + /** * Searches for resources matching the given criteria. * @@ -195,4 +279,126 @@ public function searchModels(?string $modelName = null, ?string $modelType = nul new ModelSearchParameters($modelName, $modelType) ); } + + /** + * Add a document to the RAG database and return the initial annotation. + * + * @template T of BaseRagAnnotationResponse + * @param string $responseClass The response class to construct. + * @phpstan-param class-string $responseClass + * @param LocalInputSource $inputSource Local file to upload. + * @param RagDocumentUploadParameters $params Upload parameters. + * @param PollingOptions|null $pollingOptions Options to apply to the polling. + * @param CancellationToken|null $cancellationToken CancellationToken to check for cancellation. + * @return T + * @throws MindeeException Throws if upload fails or polling times out. + */ + public function uploadAndGetRagDocumentPoll( + string $responseClass, + LocalInputSource $inputSource, + RagDocumentUploadParameters $params, + ?PollingOptions $pollingOptions = null, + ?CancellationToken $cancellationToken = null + ): BaseRagAnnotationResponse { + if (!$pollingOptions) { + $pollingOptions = new PollingOptions(); + } + $initialResponse = $this->uploadRagDocument($responseClass, $inputSource, $params); + return $this->pollForRagDocument($responseClass, $initialResponse, $pollingOptions, $cancellationToken); + } + + /** + * Get a document's info and annotations from the RAG database. + * + * @template T of BaseRagAnnotationResponse + * @param string $responseClass The response class to construct. + * @phpstan-param class-string $responseClass + * @param string $documentId Unique identifier of the RAG document. + * @param PollingOptions|null $pollingOptions Options to apply to the polling. + * @param CancellationToken|null $cancellationToken CancellationToken to check for cancellation. + * @return T + * @throws MindeeException Throws if polling times out. + */ + public function getReadyRagDocumentPoll( + string $responseClass, + string $documentId, + ?PollingOptions $pollingOptions = null, + ?CancellationToken $cancellationToken = null + ): BaseRagAnnotationResponse { + $initialResponse = $this->getRagDocument($responseClass, $documentId); + if ($initialResponse->status !== "Processing") { + return $initialResponse; + } + if (!$pollingOptions) { + $pollingOptions = new PollingOptions(); + } + return $this->pollForRagDocument($responseClass, $initialResponse, $pollingOptions, $cancellationToken); + } + + /** + * Update a document's annotations in the RAG database. + * + * @template T of ExtractionRagAnnotationResponse + * @param string $responseClass The response class to construct. + * @phpstan-param class-string $responseClass + * @param BaseAnnotationParameters $params Annotation parameters including the document ID and fields to update. + * @param PollingOptions|null $pollingOptions Options to apply to the polling. + * @param CancellationToken|null $cancellationToken CancellationToken to check for cancellation. + * @throws MindeeException Throws if polling times out. + */ + public function updateAndGetRagAnnotationPoll( + string $responseClass, + BaseAnnotationParameters $params, + ?PollingOptions $pollingOptions = null, + ?CancellationToken $cancellationToken = null + ): BaseRagAnnotationResponse { + error_log("Updating RAG document ID: " . $params->documentId); + $initialResponse = $this->updateRagAnnotation($responseClass, $params); + if ($initialResponse->status !== "Processing") { + return $initialResponse; + } + if (!$pollingOptions) { + $pollingOptions = new PollingOptions(); + } + return $this->pollForRagDocument($responseClass, $initialResponse, $pollingOptions, $cancellationToken); + } + + /** + * Poll until the RAG document is finished processing or the max number of attempts is reached. + * + * @template T of BaseRagAnnotationResponse + * @param string $responseClass The response class to construct. + * @phpstan-param class-string $responseClass + * @param BaseRagAnnotationResponse $initialResponse Initial annotation response. + * @param PollingOptions $pollingOptions Options to apply to the polling. + * @param CancellationToken|null $cancellationToken CancellationToken to check for cancellation. + * @return T + * @throws MindeeException Throws if the job fails or polling times out. + */ + private function pollForRagDocument( + string $responseClass, + BaseRagAnnotationResponse $initialResponse, + PollingOptions $pollingOptions, + ?CancellationToken $cancellationToken = null + ): BaseRagAnnotationResponse { + $documentId = $initialResponse->id; + $maxRetries = $pollingOptions->maxRetries + 1; + $this->customSleep($pollingOptions->initialDelaySec, $cancellationToken); + + $retryCounter = 1; + while ($retryCounter < $maxRetries) { + $this->customSleep($pollingOptions->delaySec, $cancellationToken); + $response = $this->getRagDocument($responseClass, $documentId); + $retryCounter++; + switch ($response->status) { + case "Processing": + break; + case "Failed": + throw new MindeeException("Job failed without an error payload."); + default: + return $response; + } + } + throw new MindeeException("RAG polling not complete after $retryCounter attempts."); + } } diff --git a/src/V2/ClientOptions/BaseAnnotationParameters.php b/src/V2/ClientOptions/BaseAnnotationParameters.php new file mode 100644 index 00000000..095bee21 --- /dev/null +++ b/src/V2/ClientOptions/BaseAnnotationParameters.php @@ -0,0 +1,21 @@ + Request parameters. + */ + abstract public function getRequestParameters(): array; +} diff --git a/src/V2/Http/MindeeApiV2.php b/src/V2/Http/MindeeApiV2.php index 162edd42..302d24ab 100644 --- a/src/V2/Http/MindeeApiV2.php +++ b/src/V2/Http/MindeeApiV2.php @@ -17,14 +17,17 @@ use Mindee\Input\InputSource; use Mindee\Input\LocalInputSource; use Mindee\Input\UrlInputSource; +use Mindee\V2\ClientOptions\BaseAnnotationParameters; use Mindee\V2\ClientOptions\BaseProductParameters; use Mindee\V2\ClientOptions\BaseSearchParameters; use Mindee\V2\Error\MindeeV2HttpException; use Mindee\V2\Error\MindeeV2HttpUnknownException; use Mindee\V2\Parsing\Error\ErrorResponse; use Mindee\V2\Parsing\Inference\BaseResponse; +use Mindee\V2\Parsing\BaseRagAnnotationResponse; use Mindee\V2\Parsing\Job\JobResponse; use Mindee\V2\Parsing\Search\BaseSearchResponse; +use Mindee\V2\Product\Extraction\RagDocuments\Params\RagDocumentUploadParameters; use ReflectionClass; use ReflectionException; use ReflectionProperty; @@ -172,10 +175,9 @@ public function reqPostEnqueue(InputSource $inputDoc, BaseProductParameters $par throw new MindeeException("Model ID must be provided.", ErrorCode::USER_INPUT_ERROR); } $response = $this->documentEnqueuePost($inputDoc, $params); - return $this->processJobResponse($response); + return $this->deserializeResponse(JobResponse::class, $response); } - /** * Process the HTTP response and return the appropriate response object. * @@ -186,7 +188,7 @@ public function reqPostEnqueue(InputSource $inputDoc, BaseProductParameters $par * @return T A response containing parsing results. * @throws MindeeException Throws if HTTP status indicates an error or deserialization fails. */ - private function processResponse( + private function deserializeResponse( string $responseClass, array $result ): BaseResponse { @@ -204,60 +206,50 @@ private function processResponse( return $instance; } catch (Exception $e) { error_log("Raised '{$e->getMessage()}' Couldn't deserialize response object:\n" . $result['data']); - throw new MindeeException("Couldn't deserialize response object.", ErrorCode::API_UNPROCESSABLE_ENTITY); + throw new MindeeException( + "Couldn't deserialize response object.", + ErrorCode::API_UNPROCESSABLE_ENTITY + ); } } /** - * Process the HTTP response and return the appropriate response object. - * - * @param array> $result Raw HTTP response array with 'data' and 'code' keys. - * @return JobResponse The processed response object. - * @throws MindeeException Throws if HTTP status indicates an error or deserialization fails. - * @throws MindeeApiException Throws if the response type is not recognized. + * Requests the job of a queued document from the API. + * Throws an error if the server's response contains one. + * @param string $jobId UUID of the job. + * @return JobResponse Server response wrapped in a JobResponse object. + * @throws MindeeException Throws if the server's response contains an error. + * @throws MindeeException Throws if the inference ID is not provided. */ - private function processJobResponse(array $result): JobResponse + public function reqGetJobById(string $jobId): JobResponse { - $this->checkValidResponse($result); - - try { - $responseData = json_decode($result['data'], true); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new MindeeException('JSON decode error: ' . json_last_error_msg()); - } - - return new JobResponse($responseData); - } catch (Exception $e) { - error_log("Raised '{$e->getMessage()}' Couldn't deserialize job response:\n" . $result['data']); - throw new MindeeApiException("Couldn't deserialize response object.", ErrorCode::API_UNPROCESSABLE_ENTITY); - } + return $this->reqGetJobFromUrl($this->baseUrl . "/v2/jobs/$jobId"); } /** * Requests the job of a queued document from the API. * Throws an error if the server's response contains one. - * @param string $jobId ID of the inference. + * @param string $url URL of the job. * @return JobResponse Server response wrapped in a JobResponse object. * @throws MindeeException Throws if the server's response contains an error. * @throws MindeeException Throws if the inference ID is not provided. */ - public function reqGetJob(string $jobId): JobResponse + public function reqGetJobFromUrl(string $url): JobResponse { - $response = $this->sendGetRequest($this->baseUrl . "/v2/jobs/$jobId"); - return $this->processJobResponse($response); + $response = $this->sendGetRequest($url); + return $this->deserializeResponse(JobResponse::class, $response); } - /** * @template T of BaseResponse * @param string $responseClass The response class to construct. * @phpstan-param class-string $responseClass - * @param string $resultId URL of the result. + * @param string $resultId UUID of the result. * @return T A response containing parsing results. * @throws MindeeException Throws if the server's response contains an error. * @throws MindeeApiException Throws if the response class is not valid. */ - public function reqGetResult( + public function reqGetResultById( string $responseClass, string $resultId ): BaseResponse { @@ -271,8 +263,7 @@ public function reqGetResult( ); } $url = $this->baseUrl . "/v2/products/{$slugProperty->getValue()}/results/$resultId"; - $response = $this->sendGetRequest($url); - return $this->processResponse($responseClass, $response); + return $this->reqGetResultFromUrl($responseClass, $url); } /** @@ -288,7 +279,7 @@ public function reqGetResultFromUrl( string $resultUrl ): BaseResponse { $response = $this->sendGetRequest($resultUrl); - return $this->processResponse($responseClass, $response); + return $this->deserializeResponse($responseClass, $response); } /** @@ -389,6 +380,146 @@ private function checkValidResponse(array $result): void } } + /** + * Uploads a local document to the RAG database. + * + * @template T of BaseRagAnnotationResponse + * @param string $responseClass The response class to construct. + * @phpstan-param class-string $responseClass + * @param LocalInputSource $inputSource Local file to upload. + * @param RagDocumentUploadParameters $params Upload parameters. + * @return T + * @throws MindeeException Throws if the cURL operation fails. + */ + public function reqPostRagDocument( + string $responseClass, + LocalInputSource $inputSource, + RagDocumentUploadParameters $params + ): BaseRagAnnotationResponse { + $ch = $this->initChannel(); + $postFields = $params->getRequestParameters(); + + $inputSource->checkNeedsFix(); + $postFields['file'] = $inputSource->fileObject; + + $url = $this->baseUrl . '/v2/products/extraction/rag-documents'; + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields); + + $resp = [ + 'data' => curl_exec($ch), + 'code' => curl_getinfo($ch, CURLINFO_HTTP_CODE), + ]; + $curlError = curl_error($ch); + curl_close($ch); + + if (!empty($curlError)) { + throw new MindeeException("cURL error:\n$curlError"); + } + + /** @var T $response */ + $response = $this->deserializeResponse($responseClass, $resp); + return $response; + } + + /** + * Makes a PATCH call with a JSON body. + * + * @param string $url URL to send the request to. + * @param array $body Request body to encode as JSON. + * @return array> Server response. + */ + private function sendPatchRequest(string $url, array $body): array + { + $ch = $this->initChannel(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH'); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: ' . $this->apiKey, + 'Content-Type: application/json', + ]); + $resp = [ + 'data' => curl_exec($ch), + 'code' => curl_getinfo($ch, CURLINFO_HTTP_CODE), + ]; + curl_close($ch); + + return $resp; + } + + /** + * Makes a DELETE call. + * + * @param string $url URL to send the request to. + * @return array> Server response. + */ + private function sendDeleteRequest(string $url): array + { + $ch = $this->initChannel(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + $resp = [ + 'data' => curl_exec($ch), + 'code' => curl_getinfo($ch, CURLINFO_HTTP_CODE), + ]; + curl_close($ch); + + return $resp; + } + + /** + * Retrieves a RAG document annotation by its ID. + * + * @template T of BaseRagAnnotationResponse + * @param string $responseClass The response class to construct. + * @phpstan-param class-string $responseClass + * @param string $documentId Unique identifier of the RAG document. + * @return T + */ + public function reqGetRagAnnotation(string $responseClass, string $documentId): BaseRagAnnotationResponse + { + $url = $this->baseUrl . "/v2/products/extraction/rag-documents/$documentId"; + $response = $this->sendGetRequest($url); + /** @var T $result */ + $result = $this->deserializeResponse($responseClass, $response); + return $result; + } + + /** + * Updates a RAG document annotation using the provided parameters. + * + * @template T of BaseRagAnnotationResponse + * @param string $responseClass The response class to construct. + * @phpstan-param class-string $responseClass + * @param BaseAnnotationParameters $params Annotation parameters including the document ID and fields to update. + * @return T + */ + public function reqPatchRagAnnotation( + string $responseClass, + BaseAnnotationParameters $params + ): BaseRagAnnotationResponse { + $url = $this->baseUrl . "/v2/products/extraction/rag-documents/{$params->documentId}"; + $response = $this->sendPatchRequest($url, $params->getRequestParameters()); + /** @var T $result */ + $result = $this->deserializeResponse($responseClass, $response); + return $result; + } + + /** + * Deletes a RAG document from the extraction database. + * + * @param string $documentId Unique identifier of the RAG document to delete. + * @return bool True if the deletion was successful (2xx response), false otherwise. + */ + public function reqDeleteExtractionRagDocument(string $documentId): bool + { + $url = $this->baseUrl . "/v2/products/extraction/rag-documents/$documentId"; + $response = $this->sendDeleteRequest($url); + $statusCode = $response['code'] ?? -1; + return $statusCode >= 200 && $statusCode < 300; + } + /** * Makes a GET call to a search endpoint and returns the deserialized response. * @@ -415,6 +546,6 @@ public function reqGetSearch(string $responseClass, BaseSearchParameters $params 'code' => curl_getinfo($ch, CURLINFO_HTTP_CODE), ]; curl_close($ch); - return $this->processResponse($responseClass, $resp); + return $this->deserializeResponse($responseClass, $resp); } } diff --git a/src/V2/Parsing/BaseRagAnnotationResponse.php b/src/V2/Parsing/BaseRagAnnotationResponse.php new file mode 100644 index 00000000..2fef88aa --- /dev/null +++ b/src/V2/Parsing/BaseRagAnnotationResponse.php @@ -0,0 +1,46 @@ + $rawResponse Raw server response array. + */ + public function __construct(array $rawResponse) + { + parent::__construct($rawResponse); + $this->id = $rawResponse['id']; + $this->filename = $rawResponse['filename']; + $this->createdAt = new DateTimeImmutable($rawResponse['created_at']); + $this->status = $rawResponse['status']; + } +} diff --git a/src/V2/Parsing/Inference/Field/ListField.php b/src/V2/Parsing/Inference/Field/ListField.php index c92034a5..6b9128be 100644 --- a/src/V2/Parsing/Inference/Field/ListField.php +++ b/src/V2/Parsing/Inference/Field/ListField.php @@ -16,10 +16,20 @@ class ListField extends BaseField { /** - * @var array Items contained in the list. + * @var array Items contained in the list, prefer getSimpleItems() or getObjectItems(). */ public array $items; + /** + * @var array|null Cached list of simple field items. + */ + private ?array $simpleItemsCache = null; + + /** + * @var array|null Cached list of object field items. + */ + private ?array $objectItemsCache = null; + /** * @param array> $rawResponse Raw server response array. * @param integer $indentLevel Level of indentation for rst display. @@ -41,6 +51,48 @@ public function __construct(array $rawResponse, int $indentLevel = 0) } } + /** + * List of simple fields. + * + * @return array + */ + public function getSimpleItems(): array + { + if ($this->simpleItemsCache !== null) { + return $this->simpleItemsCache; + } + + $this->simpleItemsCache = []; + foreach ($this->items as $item) { + if ($item instanceof SimpleField) { + $this->simpleItemsCache[] = $item; + } + } + + return $this->simpleItemsCache; + } + + /** + * List of object fields. + * + * @return array + */ + public function getObjectItems(): array + { + if ($this->objectItemsCache !== null) { + return $this->objectItemsCache; + } + + $this->objectItemsCache = []; + foreach ($this->items as $item) { + if ($item instanceof ObjectField) { + $this->objectItemsCache[] = $item; + } + } + + return $this->objectItemsCache; + } + /** */ public function __toString(): string @@ -51,10 +103,6 @@ public function __toString(): string $parts = ['']; foreach ($this->items as $item) { - if (null === $item) { - continue; - } - if ($item instanceof ObjectField) { $parts[] = $item->toStringFromList(); } else { diff --git a/src/V2/Product/Extraction/RagDocuments/AnnotatedBaseField.php b/src/V2/Product/Extraction/RagDocuments/AnnotatedBaseField.php new file mode 100644 index 00000000..04c55003 --- /dev/null +++ b/src/V2/Product/Extraction/RagDocuments/AnnotatedBaseField.php @@ -0,0 +1,54 @@ + $rawResponse Raw server response array. + * @throws MindeeApiException Throws if the field type is not recognized. + */ + public static function createField(array $rawResponse): AnnotatedSimpleField|AnnotatedObjectField|AnnotatedListField + { + if (array_key_exists('items', $rawResponse)) { + return AnnotatedListField::fromArray($rawResponse); + } + if (array_key_exists('fields', $rawResponse)) { + return AnnotatedObjectField::fromArray($rawResponse); + } + if (array_key_exists('value', $rawResponse)) { + return AnnotatedSimpleField::fromArray($rawResponse); + } + throw new MindeeApiException( + sprintf('Unrecognized annotated field format in %s.', json_encode($rawResponse)) + ); + } + + /** + * @return array Array representation for serialization. + */ + abstract public function toArray(): array; +} diff --git a/src/V2/Product/Extraction/RagDocuments/AnnotatedFields.php b/src/V2/Product/Extraction/RagDocuments/AnnotatedFields.php new file mode 100644 index 00000000..38ca3209 --- /dev/null +++ b/src/V2/Product/Extraction/RagDocuments/AnnotatedFields.php @@ -0,0 +1,102 @@ + + */ +class AnnotatedFields extends ArrayObject +{ + /** + * @var array + */ + private array $fields = []; + + /** + * @param array> $rawResponse Raw server response array. + */ + public function __construct(array $rawResponse) + { + foreach ($rawResponse as $key => $value) { + $this->fields[$key] = AnnotatedBaseField::createField($value); + } + + parent::__construct($this->fields); + } + + /** + * Get a field by key. + * + * @param string $fieldName Field key to retrieve. + * @throws InvalidArgumentException When the field does not exist. + */ + public function get(string $fieldName): AnnotatedSimpleField|AnnotatedObjectField|AnnotatedListField + { + return $this->fields[$fieldName] ?? throw new InvalidArgumentException("Field $fieldName does not exist."); + } + + /** + * Get a simple field by key. + * + * @param string $fieldName Field key to retrieve. + * @throws InvalidArgumentException When the field does not exist or is not a simple field. + */ + public function getSimpleField(string $fieldName): AnnotatedSimpleField + { + $field = $this->get($fieldName); + if ($field instanceof AnnotatedSimpleField) { + return $field; + } + throw new InvalidArgumentException("Field $fieldName is not a simple field."); + } + + /** + * Get a list field by key. + * + * @param string $fieldName Field key to retrieve. + * @throws InvalidArgumentException When the field does not exist or is not a list field. + */ + public function getListField(string $fieldName): AnnotatedListField + { + $field = $this->get($fieldName); + if ($field instanceof AnnotatedListField) { + return $field; + } + throw new InvalidArgumentException("Field $fieldName is not a list field."); + } + + /** + * Get an object field by key. + * + * @param string $fieldName Field key to retrieve. + * @throws InvalidArgumentException When the field does not exist or is not an object field. + */ + public function getObjectField(string $fieldName): AnnotatedObjectField + { + $field = $this->get($fieldName); + if ($field instanceof AnnotatedObjectField) { + return $field; + } + throw new InvalidArgumentException("Field $fieldName is not an object field."); + } + + /** + * @return array Array representation for serialization. + */ + public function toArray(): array + { + $out = []; + foreach ($this->fields as $key => $field) { + $out[$key] = $field->toArray(); + } + + return $out; + } +} diff --git a/src/V2/Product/Extraction/RagDocuments/AnnotatedListField.php b/src/V2/Product/Extraction/RagDocuments/AnnotatedListField.php new file mode 100644 index 00000000..4b930f54 --- /dev/null +++ b/src/V2/Product/Extraction/RagDocuments/AnnotatedListField.php @@ -0,0 +1,103 @@ + List of fields, prefer getSimpleItems() or getObjectItems(). + */ + public array $items = []; + + /** + * @var array|null Cached list of simple field items. + */ + private ?array $simpleItemsCache = null; + + /** + * @var array|null Cached list of object field items. + */ + private ?array $objectItemsCache = null; + + /** + */ + public function __construct(bool $selected, ?string $guidelines) + { + parent::__construct($selected, $guidelines); + } + + /** + * @param array $rawResponse Raw server response array. + */ + public static function fromArray(array $rawResponse): self + { + $selected = (bool) ($rawResponse['selected'] ?? false); + $guidelines = isset($rawResponse['guidelines']) ? (string) $rawResponse['guidelines'] : null; + $listField = new self($selected, $guidelines); + + foreach ($rawResponse['items'] ?? [] as $itemData) { + $listField->items[] = AnnotatedBaseField::createField($itemData); + } + + return $listField; + } + + /** + * List of simple fields. + * + * @return array + */ + public function getSimpleItems(): array + { + if ($this->simpleItemsCache !== null) { + return $this->simpleItemsCache; + } + + $this->simpleItemsCache = []; + foreach ($this->items as $item) { + if ($item instanceof AnnotatedSimpleField) { + $this->simpleItemsCache[] = $item; + } + } + + return $this->simpleItemsCache; + } + + /** + * List of object fields. + * + * @return array + */ + public function getObjectItems(): array + { + if ($this->objectItemsCache !== null) { + return $this->objectItemsCache; + } + + $this->objectItemsCache = []; + foreach ($this->items as $item) { + if ($item instanceof AnnotatedObjectField) { + $this->objectItemsCache[] = $item; + } + } + + return $this->objectItemsCache; + } + + /** + * @return array Array representation for serialization. + */ + public function toArray(): array + { + return [ + 'selected' => $this->selected, + 'guidelines' => $this->guidelines, + 'items' => array_map(static fn(AnnotatedBaseField $f) => $f->toArray(), $this->items), + ]; + } +} diff --git a/src/V2/Product/Extraction/RagDocuments/AnnotatedObjectField.php b/src/V2/Product/Extraction/RagDocuments/AnnotatedObjectField.php new file mode 100644 index 00000000..a28effda --- /dev/null +++ b/src/V2/Product/Extraction/RagDocuments/AnnotatedObjectField.php @@ -0,0 +1,131 @@ + $rawResponse Raw server response array. + */ + public static function fromArray(array $rawResponse): self + { + $selected = (bool) ($rawResponse['selected'] ?? false); + $guidelines = isset($rawResponse['guidelines']) ? (string) $rawResponse['guidelines'] : null; + $fields = new AnnotatedFields($rawResponse['fields'] ?? []); + + return new self($selected, $guidelines, $fields); + } + + /** + * Returns an AnnotatedSimpleField instance for the specified key. + * + * @param string $key The key of the simple field to retrieve. + * @throws InvalidArgumentException When the field does not exist or is not a simple field. + */ + public function getSimpleField(string $key): AnnotatedSimpleField + { + return $this->fields->getSimpleField($key); + } + + /** + * Returns an AnnotatedListField instance for the specified key. + * + * @param string $key The key of the list field to retrieve. + * @throws InvalidArgumentException When the field does not exist or is not a list field. + */ + public function getListField(string $key): AnnotatedListField + { + return $this->fields->getListField($key); + } + + /** + * Returns an AnnotatedObjectField instance for the specified key. + * + * @param string $key The key of the object field to retrieve. + * @throws InvalidArgumentException When the field does not exist or is not an object field. + */ + public function getObjectField(string $key): self + { + return $this->fields->getObjectField($key); + } + + /** + * Returns an array of all AnnotatedSimpleField instances in this object's fields. + * + * @return AnnotatedSimpleField[] + */ + public function getSimpleFields(): array + { + $out = []; + foreach ($this->fields->getArrayCopy() as $field) { + if ($field instanceof AnnotatedSimpleField) { + $out[] = $field; + } + } + + return $out; + } + + /** + * Returns an array of all AnnotatedListField instances in this object's fields. + * + * @return AnnotatedListField[] + */ + public function getListFields(): array + { + $out = []; + foreach ($this->fields->getArrayCopy() as $field) { + if ($field instanceof AnnotatedListField) { + $out[] = $field; + } + } + + return $out; + } + + /** + * Returns an array of all AnnotatedObjectField instances in this object's fields. + * + * @return AnnotatedObjectField[] + */ + public function getObjectFields(): array + { + $out = []; + foreach ($this->fields->getArrayCopy() as $field) { + if ($field instanceof self) { + $out[] = $field; + } + } + + return $out; + } + + /** + * @return array Array representation for serialization. + */ + public function toArray(): array + { + return [ + 'selected' => $this->selected, + 'guidelines' => $this->guidelines, + 'fields' => $this->fields->toArray(), + ]; + } +} diff --git a/src/V2/Product/Extraction/RagDocuments/AnnotatedSimpleField.php b/src/V2/Product/Extraction/RagDocuments/AnnotatedSimpleField.php new file mode 100644 index 00000000..7b9d8044 --- /dev/null +++ b/src/V2/Product/Extraction/RagDocuments/AnnotatedSimpleField.php @@ -0,0 +1,51 @@ + $rawResponse Raw server response array. + */ + public static function fromArray(array $rawResponse): self + { + $selected = (bool) ($rawResponse['selected'] ?? false); + $guidelines = isset($rawResponse['guidelines']) ? (string) $rawResponse['guidelines'] : null; + $rawValue = $rawResponse['value'] ?? null; + + if (is_int($rawValue)) { + $rawValue = (float) $rawValue; + } + + return new self($selected, $guidelines, $rawValue); + } + + /** + * @return array Array representation for serialization. + */ + public function toArray(): array + { + return [ + 'selected' => $this->selected, + 'guidelines' => $this->guidelines, + 'value' => $this->value, + ]; + } +} diff --git a/src/V2/Product/Extraction/RagDocuments/ExtractionRagAnnotationResponse.php b/src/V2/Product/Extraction/RagDocuments/ExtractionRagAnnotationResponse.php new file mode 100644 index 00000000..397a0614 --- /dev/null +++ b/src/V2/Product/Extraction/RagDocuments/ExtractionRagAnnotationResponse.php @@ -0,0 +1,49 @@ + $rawResponse Raw server response array. + */ + public function __construct(array $rawResponse) + { + parent::__construct($rawResponse); + $this->modelId = $rawResponse['model_id']; + $this->totalMatches = $rawResponse['total_matches']; + $this->lastMatchAt = DateHelper::parseDateImmutable($rawResponse['last_match_at'] ?? null); + $this->annotation = isset($rawResponse['annotation']) + ? new RagAnnotation($rawResponse['annotation']) + : null; + } +} diff --git a/src/V2/Product/Extraction/RagDocuments/Params/RagDocumentAnnotationParameters.php b/src/V2/Product/Extraction/RagDocuments/Params/RagDocumentAnnotationParameters.php new file mode 100644 index 00000000..b99d83de --- /dev/null +++ b/src/V2/Product/Extraction/RagDocuments/Params/RagDocumentAnnotationParameters.php @@ -0,0 +1,73 @@ +annotation = $annotation; + } elseif (is_string($annotation)) { + $rawAnnotation = json_decode($annotation, true); + if (!is_array($rawAnnotation)) { + throw new MindeeInputException("Invalid RAG Annotation format."); + } + $this->annotation = new RagAnnotation($rawAnnotation); + } else { + $this->annotation = null; + } + } + + /** + * @return array Request parameters. + * @throws InvalidArgumentException Throws if the document ID is missing. + */ + public function getRequestParameters(): array + { + if (empty($this->documentId)) { + throw new InvalidArgumentException("DocumentId is required in RagDocumentsAnnotationParameters"); + } + + $parameters = []; + + if ($this->status !== null) { + $parameters['status'] = $this->status; + } + + if ($this->annotation !== null) { + $parameters['annotation'] = $this->annotation->toArray(); + } + + return $parameters; + } +} diff --git a/src/V2/Product/Extraction/RagDocuments/Params/RagDocumentUploadParameters.php b/src/V2/Product/Extraction/RagDocuments/Params/RagDocumentUploadParameters.php new file mode 100644 index 00000000..7213f8fa --- /dev/null +++ b/src/V2/Product/Extraction/RagDocuments/Params/RagDocumentUploadParameters.php @@ -0,0 +1,31 @@ + Request parameters. + * @throws InvalidArgumentException Throws if the model ID is missing. + */ + public function getRequestParameters(): array + { + if (empty($this->modelId)) { + throw new InvalidArgumentException("ModelId is required in RagDocumentsParameters"); + } + + return ['model_id' => $this->modelId]; + } +} diff --git a/src/V2/Product/Extraction/RagDocuments/RagAnnotation.php b/src/V2/Product/Extraction/RagDocuments/RagAnnotation.php new file mode 100644 index 00000000..32398cb9 --- /dev/null +++ b/src/V2/Product/Extraction/RagDocuments/RagAnnotation.php @@ -0,0 +1,32 @@ + $rawResponse Raw server response array. + */ + public function __construct(array $rawResponse) + { + $this->fields = new AnnotatedFields($rawResponse['fields'] ?? []); + } + + /** + * @return array Array representation for serialization. + */ + public function toArray(): array + { + return ['fields' => $this->fields->toArray()]; + } +} diff --git a/tests/V2/ClientV2Test.php b/tests/V2/ClientV2Test.php index 8d4edbad..5ffe9b15 100644 --- a/tests/V2/ClientV2Test.php +++ b/tests/V2/ClientV2Test.php @@ -61,7 +61,7 @@ public function testDocumentGetJobAsync(): void $processing = new JobResponse(json_decode($syntheticResponse, true)); $predictable->expects(self::once()) - ->method('reqGetJob') + ->method('reqGetJobById') ->with(self::equalTo('dummy-id')) ->willReturn($processing); @@ -85,7 +85,7 @@ public function testDocumentGetInferenceAsync(): void $processing = new ExtractionResponse($json); $predictable->expects(self::once()) - ->method('reqGetResult') + ->method('reqGetResultById') ->with( self::equalTo(ExtractionResponse::class), self::equalTo('12345678-1234-1234-1234-123456789abc') diff --git a/tests/V2/Parsing/ExtractionResponseTest.php b/tests/V2/Product/Extraction/ExtractionTest.php similarity index 93% rename from tests/V2/Parsing/ExtractionResponseTest.php rename to tests/V2/Product/Extraction/ExtractionTest.php index ad6e85b8..c78adc63 100644 --- a/tests/V2/Parsing/ExtractionResponseTest.php +++ b/tests/V2/Product/Extraction/ExtractionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace V2\Parsing; +namespace V2\Product\Extraction; use Mindee\Geometry\Point; use Mindee\Input\LocalResponse; @@ -17,12 +17,12 @@ use PHPUnit\Framework\TestCase; use TestingUtilities; -require_once(__DIR__ . "/../../TestingUtilities.php"); +require_once(__DIR__ . "/../../../TestingUtilities.php"); /** * InferenceV2 – field integrity checks */ -class ExtractionResponseTest extends TestCase +class ExtractionTest extends TestCase { private function loadFromResource(string $resourcePath): ExtractionResponse { @@ -118,39 +118,30 @@ public function testAsyncPredictWhenCompleteMustExposeAllProperties(): void $fields = $inference->result->fields; self::assertCount(21, $fields, 'Expected 21 fields in the payload'); - $date = $fields->get('date'); - self::assertInstanceOf(SimpleField::class, $date); + $date = $fields->getSimpleField('date'); self::assertSame('2019-11-02', $date->getStringValue(), "'date' value mismatch"); $taxes = $fields->getListField('taxes'); self::assertNotNull($taxes, "'taxes' field must exist"); - self::assertInstanceOf(ListField::class, $taxes, "'taxes' must be a ListField"); - self::assertCount(1, $taxes->items, "'taxes' list must contain exactly one item"); + self::assertCount(1, $taxes->getObjectItems(), "'taxes' list must contain exactly one item"); - $taxItemObj = $taxes->items[0]; - self::assertInstanceOf(ObjectField::class, $taxItemObj, 'First item of "taxes" must be an ObjectField'); + $taxItemObj = $taxes->getObjectItems()[0]; self::assertCount(3, $taxItemObj->fields, 'Tax ObjectField must contain 3 sub-fields'); - $baseTax = $taxItemObj->fields->get('base'); - self::assertInstanceOf(SimpleField::class, $baseTax); + $baseTax = $taxItemObj->getSimpleField('base'); self::assertSame(31.5, $baseTax->getFloatValue(), "'taxes.base' value mismatch"); self::assertNotNull((string) $taxes, "'taxes'.__toString() must not be null"); $supplierAddress = $fields->getObjectField('supplier_address'); self::assertNotNull($supplierAddress, "'supplier_address' field must exist"); - self::assertInstanceOf(ObjectField::class, $supplierAddress, "'supplier_address' must be an ObjectField"); - $country = $supplierAddress->fields->get('country'); - self::assertNotNull($country, "'supplier_address.country' must exist"); - self::assertInstanceOf(SimpleField::class, $country); + $country = $supplierAddress->getSimpleField('country'); self::assertSame('USA', $country->getStringValue(), 'Country mismatch'); self::assertSame('USA', (string) $country, "'country'.__toString() mismatch"); self::assertNotNull((string) $supplierAddress, "'supplier_address'.__toString() must not be null"); - $customerAddr = $fields->get('customer_address'); - self::assertInstanceOf(ObjectField::class, $customerAddr); - $city = $customerAddr->fields->get('city'); - self::assertInstanceOf(SimpleField::class, $city); + $customerAddr = $fields->getObjectField('customer_address'); + $city = $customerAddr->getSimpleField('city'); self::assertSame('New York', $city->getStringValue(), 'City mismatch'); self::assertNull($inference->result->options ?? null, 'Options must be null'); diff --git a/tests/V2/Product/Extraction/RagDocumentsFunctional.php b/tests/V2/Product/Extraction/RagDocumentsFunctional.php new file mode 100644 index 00000000..afcc5ede --- /dev/null +++ b/tests/V2/Product/Extraction/RagDocumentsFunctional.php @@ -0,0 +1,116 @@ +client = new Client(getenv('MINDEE_V2_API_KEY') ?: null); + $this->extractionModelId = getenv('MINDEE_V2_FINDOC_MODEL_ID') ?: ''; + } + + public function testRagDocumentLifecycleMustSucceed(): void + { + $inputSource = new PathInput( + TestingUtilities::getV2ProductDir() . '/extraction/financial_document/default_sample.jpg' + ); + $parameters = new RagDocumentUploadParameters(modelId: $this->extractionModelId); + + $postResponse = $this->client->uploadAndGetRagDocumentPoll( + ExtractionRagAnnotationResponse::class, + $inputSource, + $parameters + ); + self::assertNotNull($postResponse); + + $postAnnotation = $postResponse->annotation; + self::assertNotNull($postAnnotation->fields); + + $documentId = $postResponse->id; + self::assertNotNull($documentId); + + self::assertEquals("Draft", $postResponse->status); + + $postAnnotation->fields->getSimpleField('supplier_name')->selected = true; + $postAnnotation->fields->getSimpleField('supplier_name')->guidelines = "I am the walrus!"; + $postAnnotation->fields->getSimpleField('invoice_number')->selected = true; + $postAnnotation->fields->getSimpleField('invoice_number')->guidelines = "koo koo katchoo!"; + + $patchAnnotationResponse = $this->client->updateRagAnnotation( + ExtractionRagAnnotationResponse::class, + new RagDocumentAnnotationParameters( + documentId: $documentId, + annotation: $postAnnotation + ) + ); + self::assertNotNull($patchAnnotationResponse); + $patchAnnotation = $patchAnnotationResponse->annotation; + self::assertEquals( + "I am the walrus!", + $patchAnnotation->fields->getSimpleField('supplier_name')->guidelines + ); + self::assertTrue($patchAnnotation->fields->getSimpleField('supplier_name')->selected); + self::assertEquals( + "koo koo katchoo!", + $patchAnnotation->fields->getSimpleField('invoice_number')->guidelines + ); + self::assertTrue($patchAnnotation->fields->getSimpleField('invoice_number')->selected); + + $getResponse = $this->client->getReadyRagDocumentPoll( + ExtractionRagAnnotationResponse::class, + $documentId + ); + self::assertNotNull($getResponse); + $getAnnotation = $getResponse->annotation; + self::assertNotNull($getAnnotation); + + self::assertEquals("Draft", $getResponse->status); + + self::assertEquals( + "I am the walrus!", + $getAnnotation->fields->getSimpleField('supplier_name')->guidelines + ); + self::assertTrue($getAnnotation->fields->getSimpleField('supplier_name')->selected); + self::assertEquals( + "koo koo katchoo!", + $getAnnotation->fields->getSimpleField('invoice_number')->guidelines + ); + self::assertTrue($getAnnotation->fields->getSimpleField('invoice_number')->selected); + + $patchStatusResponse = $this->client->updateRagAnnotation( + ExtractionRagAnnotationResponse::class, + new RagDocumentAnnotationParameters( + documentId: $documentId, + status: "Active" + ) + ); + self::assertNotNull($patchStatusResponse); + self::assertEquals("Active", $patchStatusResponse->status); + + $deleteResponse = $this->client->deleteExtractionRagDocument($documentId); + self::assertTrue($deleteResponse); + + $this->expectException(MindeeV2HttpException::class); + $this->client->getRagDocument(ExtractionRagAnnotationResponse::class, $documentId); + } +} diff --git a/tests/V2/Product/Extraction/RagDocumentsTest.php b/tests/V2/Product/Extraction/RagDocumentsTest.php new file mode 100644 index 00000000..babab177 --- /dev/null +++ b/tests/V2/Product/Extraction/RagDocumentsTest.php @@ -0,0 +1,142 @@ +getRequestParameters(); + self::assertEquals("invalid-model-id", $reqParams['model_id']); + } + + public function testPatchParametersMustInit(): void + { + $annotation = new RagAnnotation([]); + $parameters = new RagDocumentAnnotationParameters( + documentId: "invalid-document-id", + status: "Active", + annotation: $annotation + ); + $reqParams = $parameters->getRequestParameters(); + self::assertEquals("invalid-document-id", $parameters->documentId); + self::assertEquals("Active", $reqParams['status']); + self::assertEquals($annotation->toArray(), $reqParams['annotation']); + } + + public function testRagDocumentsPostMustHaveValidProperties(): void + { + $response = $this->getResponse("post_response.json"); + self::assertNotNull($response); + self::assertEquals("cc831599-c545-48b7-aa27-6d7ccd5b8d32", $response->id); + self::assertEquals("Processing", $response->status); + self::assertNull($response->annotation); + } + + public function testRagDocumentsGetDraftMustHaveValidProperties(): void + { + $response = $this->getResponse("get_response_draft.json"); + self::assertNotNull($response); + self::assertEquals("cc831599-c545-48b7-aa27-6d7ccd5b8d32", $response->id); + self::assertEquals("Draft", $response->status); + self::assertNotNull($response->annotation); + + $fields = $response->annotation->fields; + self::assertNotNull($fields); + + // null simple field + $tipField = $fields->getSimpleField('tip'); + self::assertFalse($tipField->selected); + self::assertNull($tipField->guidelines); + self::assertNull($tipField->value); + + // filled simple field + $dateField = $fields->getSimpleField('date'); + self::assertFalse($dateField->selected); + self::assertNull($dateField->guidelines); + self::assertEquals("2019-11-02", $dateField->value); + + // filled object field + $localeField = $fields->getObjectField('locale'); + self::assertFalse($localeField->selected); + self::assertNull($localeField->guidelines); + self::assertNotNull($localeField->fields); + self::assertCount(3, $localeField->fields); + self::assertEquals("US", $localeField->getSimpleField('country')->value); + self::assertEquals("USD", $localeField->getSimpleField('currency')->value); + self::assertNull($localeField->getSimpleField('language')->value); + + // list of simple fields + $referenceNumbersField = $fields->getListField('reference_numbers'); + self::assertFalse($referenceNumbersField->selected); + self::assertNull($referenceNumbersField->guidelines); + self::assertCount(1, $referenceNumbersField->getSimpleItems()); + self::assertEquals("2412/2019", $referenceNumbersField->getSimpleItems()[0]->value); + + // list of object fields + $lineItemsField = $fields->getListField('line_items'); + self::assertFalse($lineItemsField->selected); + self::assertNull($lineItemsField->guidelines); + self::assertCount(3, $lineItemsField->getObjectItems()); + + $lineItem0 = $lineItemsField->getObjectItems()[0]; + self::assertNotNull($lineItem0->fields); + self::assertCount(8, $lineItem0->fields); + self::assertEquals("Front and rear brake cables", $lineItem0->getSimpleField('description')->value); + self::assertEquals(1.0, $lineItem0->getSimpleField('quantity')->value); + self::assertEquals(100.0, $lineItem0->getSimpleField('unit_price')->value); + self::assertEquals(100.0, $lineItem0->getSimpleField('total_price')->value); + self::assertNull($lineItem0->getSimpleField('tax_rate')->value); + self::assertNull($lineItem0->getSimpleField('tax_amount')->value); + self::assertNull($lineItem0->getSimpleField('product_code')->value); + self::assertNull($lineItem0->getSimpleField('unit_measure')->value); + + $lineItem1 = $lineItemsField->getObjectItems()[1]; + self::assertNotNull($lineItem1->fields); + self::assertCount(8, $lineItem1->fields); + self::assertEquals("New set of pedal arms", $lineItem1->getSimpleField('description')->value); + self::assertEquals(2.0, $lineItem1->getSimpleField('quantity')->value); + self::assertEquals(25.0, $lineItem1->getSimpleField('unit_price')->value); + self::assertEquals(50.0, $lineItem1->getSimpleField('total_price')->value); + self::assertNull($lineItem1->getSimpleField('tax_rate')->value); + self::assertNull($lineItem1->getSimpleField('tax_amount')->value); + self::assertNull($lineItem1->getSimpleField('product_code')->value); + self::assertNull($lineItem1->getSimpleField('unit_measure')->value); + + $lineItem2 = $lineItemsField->getObjectItems()[2]; + self::assertNotNull($lineItem2->fields); + self::assertCount(8, $lineItem2->fields); + self::assertEquals("Labor 3hrs", $lineItem2->getSimpleField('description')->value); + self::assertEquals(3.0, $lineItem2->getSimpleField('quantity')->value); + self::assertEquals(15.0, $lineItem2->getSimpleField('unit_price')->value); + self::assertEquals(45.0, $lineItem2->getSimpleField('total_price')->value); + self::assertNull($lineItem2->getSimpleField('tax_rate')->value); + self::assertNull($lineItem2->getSimpleField('tax_amount')->value); + self::assertNull($lineItem2->getSimpleField('product_code')->value); + self::assertNull($lineItem2->getSimpleField('unit_measure')->value); + } +}