From b44e8623f93efb9b8dbfa88bdb50783a3f7f0a1e Mon Sep 17 00:00:00 2001 From: Lauri Saarni Date: Wed, 29 Jul 2026 08:38:20 +0300 Subject: [PATCH 1/3] Add text extraction (OCR / document parsing) capability (PoC) Adds a provider-agnostic text extraction capability, following the structural pattern established by embedding generation in 1.4.0 (dedicated builder, result type, and requirements factory): - CapabilityEnum::TEXT_EXTRACTION with magic accessors. - TextExtractionModelInterface::extractTextResult(File): TextExtractionResult. - Result DTOs: TextExtractionResult (ResultInterface, non-candidate-based), ExtractedPage (1-based page numbers, markdown content), ExtractedImage, BoundingBox (normalized 0-1 coordinates), PageDimensions. - ModelRequirements::fromExtractionData() mapping the document's MIME type to a document or image input-modality requirement. - TextExtractionBuilder with withDocument($document, $mimeType) plus AiClient::document() / extractTextResult() / extractText() entry points. Validated end to end by two downstream provider PoCs with intentionally different API shapes: ai-provider-for-mistral (synchronous dedicated OCR endpoint) and ai-provider-for-llamaparse (async job-based parsing with internal polling). Co-Authored-By: Claude Fable 5 --- src/AiClient.php | 71 +++- src/Builders/TextExtractionBuilder.php | 169 +++++++++ .../Models/DTO/ModelRequirements.php | 28 ++ src/Providers/Models/Enums/CapabilityEnum.php | 9 + .../TextExtractionModelInterface.php | 29 ++ src/Results/DTO/BoundingBox.php | 210 +++++++++++ src/Results/DTO/ExtractedImage.php | 183 ++++++++++ src/Results/DTO/ExtractedPage.php | 240 ++++++++++++ src/Results/DTO/PageDimensions.php | 176 +++++++++ src/Results/DTO/TextExtractionResult.php | 345 ++++++++++++++++++ .../Models/Enums/CapabilityEnumTest.php | 1 + .../Results/DTO/TextExtractionResultTest.php | 158 ++++++++ 12 files changed, 1618 insertions(+), 1 deletion(-) create mode 100644 src/Builders/TextExtractionBuilder.php create mode 100644 src/Providers/Models/TextExtraction/Contracts/TextExtractionModelInterface.php create mode 100644 src/Results/DTO/BoundingBox.php create mode 100644 src/Results/DTO/ExtractedImage.php create mode 100644 src/Results/DTO/ExtractedPage.php create mode 100644 src/Results/DTO/PageDimensions.php create mode 100644 src/Results/DTO/TextExtractionResult.php create mode 100644 tests/unit/Results/DTO/TextExtractionResultTest.php diff --git a/src/AiClient.php b/src/AiClient.php index feee25e2..168b8c47 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -8,6 +8,7 @@ use Psr\SimpleCache\CacheInterface; use WordPress\AiClient\Builders\EmbeddingBuilder; use WordPress\AiClient\Builders\PromptBuilder; +use WordPress\AiClient\Builders\TextExtractionBuilder; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; @@ -19,6 +20,7 @@ use WordPress\AiClient\Results\DTO\Embedding; use WordPress\AiClient\Results\DTO\EmbeddingResult; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Results\DTO\TextExtractionResult; /** * Main AI Client class providing both fluent and traditional APIs for AI operations. @@ -84,6 +86,7 @@ * @phpstan-import-type Prompt from PromptBuilder * @phpstan-import-type EmbeddingInput from EmbeddingBuilder * @phpstan-import-type ProviderModelTuple from ModelResolver + * @phpstan-import-type DocumentInput from TextExtractionBuilder * * phpcs:ignore Generic.Files.LineLength.TooLong */ @@ -271,6 +274,30 @@ public static function input($input = null, ?ProviderRegistry $registry = null): ); } + /** + * Creates a text extraction builder for fluent document text extraction (OCR / document parsing). + * + * Chain extractTextResult() to get the structured, per-page result, or extractText() to get + * the extracted content as a single markdown string. + * + * @since n.e.x.t + * + * @param DocumentInput|null $document Optional document to extract text from: a File instance, + * or a string URL, data URI, base64 data, or local file path. + * When the MIME type cannot be inferred from the input (e.g. + * an extensionless URL), pass a File instance with an explicit + * MIME type, or use the builder's withDocument($document, $mimeType). + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return TextExtractionBuilder The text extraction builder instance. + */ + public static function document($document = null, ?ProviderRegistry $registry = null): TextExtractionBuilder + { + return new TextExtractionBuilder( + $registry ?? self::defaultRegistry(), + $document + ); + } + /** * Generates content using a unified API that automatically detects model capabilities. * @@ -492,6 +519,48 @@ public static function generateEmbeddings( ->generateEmbeddings(); } + /** + * Extracts text from a document using the traditional API approach. + * + * @since n.e.x.t + * + * @param DocumentInput $document The document to extract text from. + * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, + * or model configuration for auto-discovery, + * or null for defaults. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return TextExtractionResult The structured extraction result. + */ + public static function extractTextResult( + $document, + $modelOrConfig = null, + ?ProviderRegistry $registry = null + ): TextExtractionResult { + self::validateModelOrConfigParameter($modelOrConfig); + return self::applyModelOrConfig(self::document($document, $registry), $modelOrConfig) + ->extractTextResult(); + } + + /** + * Extracts text from a document and returns it as a single markdown string. + * + * @since n.e.x.t + * + * @param DocumentInput $document The document to extract text from. + * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, + * or model configuration for auto-discovery, + * or null for defaults. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return string The extracted content, pages joined in order. + */ + public static function extractText( + $document, + $modelOrConfig = null, + ?ProviderRegistry $registry = null + ): string { + return self::extractTextResult($document, $modelOrConfig, $registry)->toMarkdown(); + } + /** * Creates a new message builder for fluent API usage. * @@ -604,7 +673,7 @@ private static function getConfiguredEmbeddingBuilder( * Works with any builder that exposes the shared model resolution methods * (see {@see \WordPress\AiClient\Builders\Traits\ModelResolutionTrait}). * - * @template T of PromptBuilder + * @template T of PromptBuilder|TextExtractionBuilder * * @param T $builder The builder to configure. * @param ModelInterface|ModelConfig|null $modelOrConfig Specific model, model configuration, diff --git a/src/Builders/TextExtractionBuilder.php b/src/Builders/TextExtractionBuilder.php new file mode 100644 index 00000000..7c1a0b77 --- /dev/null +++ b/src/Builders/TextExtractionBuilder.php @@ -0,0 +1,169 @@ +modelConfig = new ModelConfig(); + $this->modelResolver = new ModelResolver($registry); + + if ($document !== null) { + $this->withDocument($document); + } + } + + /** + * Creates a deep clone of this builder. + * + * Clones the document and model configuration. Service objects are intentionally NOT cloned + * as they are shared dependencies. + * + * @since n.e.x.t + */ + public function __clone() + { + if ($this->document !== null) { + $this->document = clone $this->document; + } + + $this->modelConfig = clone $this->modelConfig; + $this->modelResolver = clone $this->modelResolver; + } + + /** + * Sets the document to extract text from. + * + * @since n.e.x.t + * + * @param DocumentInput $document The document: a File instance, or a string URL, + * data URI, base64 data, or local file path. + * @param string|null $mimeType Optional MIME type of the document. Required when it + * cannot be inferred from the input (e.g. an extensionless + * URL such as `https://arxiv.org/pdf/1805.04770`). Ignored + * when a File instance is given. + * @return self + * @throws InvalidArgumentException If the document input is invalid. + */ + public function withDocument($document, ?string $mimeType = null): self + { + if (is_string($document)) { + if (trim($document) === '') { + throw new InvalidArgumentException('Cannot create a document from an empty string.'); + } + $document = new File($document, $mimeType); + } + + if (!$document instanceof File) { + throw new InvalidArgumentException('Document must be a File instance or a string.'); + } + + $this->document = $document; + + return $this; + } + + /** + * Checks whether the current document and configuration are supported by an available model. + * + * @since n.e.x.t + * + * @return bool True if a suitable text extraction model is available. + */ + public function isSupported(): bool + { + if ($this->document === null) { + return false; + } + + $requirements = ModelRequirements::fromExtractionData($this->document, $this->modelConfig); + + return $this->modelResolver->isSupported($requirements); + } + + /** + * Extracts text from the configured document. + * + * @since n.e.x.t + * + * @return TextExtractionResult The structured extraction result. + * @throws InvalidArgumentException If no document is configured or model validation fails. + * @throws RuntimeException If the resolved model doesn't support text extraction. + */ + public function extractTextResult(): TextExtractionResult + { + if ($this->document === null) { + throw new InvalidArgumentException( + 'Cannot extract text without a document. Add one using withDocument().' + ); + } + + $requirements = ModelRequirements::fromExtractionData($this->document, $this->modelConfig); + $model = $this->modelResolver->resolve($requirements, $this->modelConfig); + + if (!$model instanceof TextExtractionModelInterface) { + throw new RuntimeException( + sprintf( + 'Model "%s" does not support text extraction.', + $model->metadata()->getId() + ) + ); + } + + return $model->extractTextResult($this->document); + } + + /** + * Extracts text from the configured document and returns it as a single markdown string. + * + * @since n.e.x.t + * + * @return string The extracted content, pages joined in order. + * @throws InvalidArgumentException If no document is configured or model validation fails. + * @throws RuntimeException If the resolved model doesn't support text extraction. + */ + public function extractText(): string + { + return $this->extractTextResult()->toMarkdown(); + } +} diff --git a/src/Providers/Models/DTO/ModelRequirements.php b/src/Providers/Models/DTO/ModelRequirements.php index 2441b2e4..f35a8777 100644 --- a/src/Providers/Models/DTO/ModelRequirements.php +++ b/src/Providers/Models/DTO/ModelRequirements.php @@ -6,6 +6,7 @@ use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\ModalityEnum; @@ -267,6 +268,33 @@ public static function fromEmbeddingData(array $inputs, ModelConfig $modelConfig return new self($capabilities, $requiredOptions); } + /** + * Creates ModelRequirements from a text extraction document and model configuration. + * + * The document contributes its input modality: image files require image input support, + * while documents and text files require document input support. + * + * @since n.e.x.t + * + * @param File $document The document to extract text from. + * @param ModelConfig $modelConfig The model configuration. + * @return self The created requirements. + */ + public static function fromExtractionData(File $document, ModelConfig $modelConfig): self + { + $capabilities = [CapabilityEnum::textExtraction()]; + + $requiredOptions = self::toRequiredOptions($modelConfig); + + $inputModality = $document->isImage() ? ModalityEnum::image() : ModalityEnum::document(); + $requiredOptions = self::includeInRequiredOptions( + $requiredOptions, + new RequiredOption(OptionEnum::inputModalities(), [$inputModality]) + ); + + return new self($capabilities, $requiredOptions); + } + /** * Determines the input modality contributed by a message part, if any. * diff --git a/src/Providers/Models/Enums/CapabilityEnum.php b/src/Providers/Models/Enums/CapabilityEnum.php index 43fc8403..d68bd258 100644 --- a/src/Providers/Models/Enums/CapabilityEnum.php +++ b/src/Providers/Models/Enums/CapabilityEnum.php @@ -18,6 +18,7 @@ * @method static self musicGeneration() Creates an instance for MUSIC_GENERATION capability. * @method static self videoGeneration() Creates an instance for VIDEO_GENERATION capability. * @method static self embeddingGeneration() Creates an instance for EMBEDDING_GENERATION capability. + * @method static self textExtraction() Creates an instance for TEXT_EXTRACTION capability. * @method static self chatHistory() Creates an instance for CHAT_HISTORY capability. * @method bool isTextGeneration() Checks if the capability is TEXT_GENERATION. * @method bool isImageGeneration() Checks if the capability is IMAGE_GENERATION. @@ -26,6 +27,7 @@ * @method bool isMusicGeneration() Checks if the capability is MUSIC_GENERATION. * @method bool isVideoGeneration() Checks if the capability is VIDEO_GENERATION. * @method bool isEmbeddingGeneration() Checks if the capability is EMBEDDING_GENERATION. + * @method bool isTextExtraction() Checks if the capability is TEXT_EXTRACTION. * @method bool isChatHistory() Checks if the capability is CHAT_HISTORY. */ class CapabilityEnum extends AbstractEnum @@ -65,6 +67,13 @@ class CapabilityEnum extends AbstractEnum */ public const EMBEDDING_GENERATION = 'embedding_generation'; + /** + * Text extraction (OCR / document parsing) capability. + * + * @since n.e.x.t + */ + public const TEXT_EXTRACTION = 'text_extraction'; + /** * Chat history support capability. */ diff --git a/src/Providers/Models/TextExtraction/Contracts/TextExtractionModelInterface.php b/src/Providers/Models/TextExtraction/Contracts/TextExtractionModelInterface.php new file mode 100644 index 00000000..31442f56 --- /dev/null +++ b/src/Providers/Models/TextExtraction/Contracts/TextExtractionModelInterface.php @@ -0,0 +1,29 @@ + + */ +class BoundingBox extends AbstractDataTransferObject +{ + public const KEY_LEFT = 'left'; + public const KEY_TOP = 'top'; + public const KEY_WIDTH = 'width'; + public const KEY_HEIGHT = 'height'; + + /** + * @var float Normalized left offset (0–1). + */ + private float $left; + + /** + * @var float Normalized top offset (0–1). + */ + private float $top; + + /** + * @var float Normalized width (0–1). + */ + private float $width; + + /** + * @var float Normalized height (0–1). + */ + private float $height; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param float $left Normalized left offset (0–1). + * @param float $top Normalized top offset (0–1). + * @param float $width Normalized width (0–1). + * @param float $height Normalized height (0–1). + */ + public function __construct(float $left, float $top, float $width, float $height) + { + foreach (['left' => $left, 'top' => $top, 'width' => $width, 'height' => $height] as $name => $value) { + if ($value < 0.0 || $value > 1.0) { + throw new InvalidArgumentException( + sprintf('Bounding box %s must be a normalized value between 0 and 1.', $name) + ); + } + } + + $this->left = $left; + $this->top = $top; + $this->width = $width; + $this->height = $height; + } + + /** + * Gets the normalized left offset. + * + * @since n.e.x.t + * + * @return float The left offset (0–1). + */ + public function getLeft(): float + { + return $this->left; + } + + /** + * Gets the normalized top offset. + * + * @since n.e.x.t + * + * @return float The top offset (0–1). + */ + public function getTop(): float + { + return $this->top; + } + + /** + * Gets the normalized width. + * + * @since n.e.x.t + * + * @return float The width (0–1). + */ + public function getWidth(): float + { + return $this->width; + } + + /** + * Gets the normalized height. + * + * @since n.e.x.t + * + * @return float The height (0–1). + */ + public function getHeight(): float + { + return $this->height; + } + + /** + * Gets the JSON schema for a bounding box. + * + * @since n.e.x.t + * + * @return array The JSON schema. + */ + public static function getJsonSchema(): array + { + $coordinate = [ + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 1, + ]; + + return [ + 'type' => 'object', + 'properties' => [ + self::KEY_LEFT => array_merge($coordinate, [ + 'description' => 'Normalized left offset relative to the page width.', + ]), + self::KEY_TOP => array_merge($coordinate, [ + 'description' => 'Normalized top offset relative to the page height.', + ]), + self::KEY_WIDTH => array_merge($coordinate, [ + 'description' => 'Normalized width relative to the page width.', + ]), + self::KEY_HEIGHT => array_merge($coordinate, [ + 'description' => 'Normalized height relative to the page height.', + ]), + ], + 'required' => [ + self::KEY_LEFT, + self::KEY_TOP, + self::KEY_WIDTH, + self::KEY_HEIGHT, + ], + ]; + } + + /** + * Converts the bounding box to an array. + * + * @since n.e.x.t + * + * @return BoundingBoxArrayShape The bounding box array. + */ + public function toArray(): array + { + return [ + self::KEY_LEFT => $this->left, + self::KEY_TOP => $this->top, + self::KEY_WIDTH => $this->width, + self::KEY_HEIGHT => $this->height, + ]; + } + + /** + * Creates a bounding box from an array. + * + * @since n.e.x.t + * + * @param BoundingBoxArrayShape $array The bounding box array. + * @return self The bounding box instance. + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [ + self::KEY_LEFT, + self::KEY_TOP, + self::KEY_WIDTH, + self::KEY_HEIGHT, + ]); + + return new self( + (float) $array[self::KEY_LEFT], + (float) $array[self::KEY_TOP], + (float) $array[self::KEY_WIDTH], + (float) $array[self::KEY_HEIGHT] + ); + } +} diff --git a/src/Results/DTO/ExtractedImage.php b/src/Results/DTO/ExtractedImage.php new file mode 100644 index 00000000..a04df601 --- /dev/null +++ b/src/Results/DTO/ExtractedImage.php @@ -0,0 +1,183 @@ + + */ +class ExtractedImage extends AbstractDataTransferObject +{ + public const KEY_ID = 'id'; + public const KEY_FILE = 'file'; + public const KEY_BOUNDING_BOX = 'boundingBox'; + + /** + * @var string Identifier of the image within the document (e.g. a filename referenced by the page markdown). + */ + private string $id; + + /** + * @var File|null The image data, when the provider returned it. + */ + private ?File $file; + + /** + * @var BoundingBox|null The image location on the page, when the provider reported it. + */ + private ?BoundingBox $boundingBox; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param string $id Identifier of the image within the document. + * @param File|null $file The image data, when returned by the provider. + * @param BoundingBox|null $boundingBox The image location on the page, when reported. + */ + public function __construct(string $id, ?File $file = null, ?BoundingBox $boundingBox = null) + { + $this->id = $id; + $this->file = $file; + $this->boundingBox = $boundingBox; + } + + /** + * Gets the image identifier. + * + * @since n.e.x.t + * + * @return string The identifier. + */ + public function getId(): string + { + return $this->id; + } + + /** + * Gets the image data, when the provider returned it. + * + * @since n.e.x.t + * + * @return File|null The image file, or null if image data was not requested/returned. + */ + public function getFile(): ?File + { + return $this->file; + } + + /** + * Gets the image location on the page, when the provider reported it. + * + * @since n.e.x.t + * + * @return BoundingBox|null The bounding box, or null if not reported. + */ + public function getBoundingBox(): ?BoundingBox + { + return $this->boundingBox; + } + + /** + * Gets the JSON schema for an extracted image. + * + * @since n.e.x.t + * + * @return array The JSON schema. + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + self::KEY_ID => [ + 'type' => 'string', + 'description' => 'Identifier of the image within the document.', + ], + self::KEY_FILE => File::getJsonSchema(), + self::KEY_BOUNDING_BOX => BoundingBox::getJsonSchema(), + ], + 'required' => [ + self::KEY_ID, + ], + ]; + } + + /** + * Converts the extracted image to an array. + * + * @since n.e.x.t + * + * @return ExtractedImageArrayShape The extracted image array. + */ + public function toArray(): array + { + $data = [ + self::KEY_ID => $this->id, + ]; + + if ($this->file !== null) { + $data[self::KEY_FILE] = $this->file->toArray(); + } + + if ($this->boundingBox !== null) { + $data[self::KEY_BOUNDING_BOX] = $this->boundingBox->toArray(); + } + + return $data; + } + + /** + * Creates an extracted image from an array. + * + * @since n.e.x.t + * + * @param ExtractedImageArrayShape $array The extracted image array. + * @return self The extracted image instance. + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [ + self::KEY_ID, + ]); + + return new self( + $array[self::KEY_ID], + isset($array[self::KEY_FILE]) ? File::fromArray($array[self::KEY_FILE]) : null, + isset($array[self::KEY_BOUNDING_BOX]) ? BoundingBox::fromArray($array[self::KEY_BOUNDING_BOX]) : null + ); + } + + /** + * Creates a deep clone of this extracted image. + * + * @since n.e.x.t + */ + public function __clone() + { + if ($this->file !== null) { + $this->file = clone $this->file; + } + if ($this->boundingBox !== null) { + $this->boundingBox = clone $this->boundingBox; + } + } +} diff --git a/src/Results/DTO/ExtractedPage.php b/src/Results/DTO/ExtractedPage.php new file mode 100644 index 00000000..ca18ef13 --- /dev/null +++ b/src/Results/DTO/ExtractedPage.php @@ -0,0 +1,240 @@ +, + * dimensions?: PageDimensionsArrayShape + * } + * + * @extends AbstractDataTransferObject + */ +class ExtractedPage extends AbstractDataTransferObject +{ + public const KEY_PAGE_NUMBER = 'pageNumber'; + public const KEY_MARKDOWN = 'markdown'; + public const KEY_IMAGES = 'images'; + public const KEY_DIMENSIONS = 'dimensions'; + + /** + * @var int The 1-based page number. + */ + private int $pageNumber; + + /** + * @var string The extracted page content as markdown. + */ + private string $markdown; + + /** + * @var list Images embedded in the page. + */ + private array $images; + + /** + * @var PageDimensions|null The page dimensions, when reported by the provider. + */ + private ?PageDimensions $dimensions; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param int $pageNumber The 1-based page number. + * @param string $markdown The extracted page content as markdown. + * @param list $images Images embedded in the page. + * @param PageDimensions|null $dimensions The page dimensions, when reported. + */ + public function __construct( + int $pageNumber, + string $markdown, + array $images = [], + ?PageDimensions $dimensions = null + ) { + if ($pageNumber < 1) { + throw new InvalidArgumentException('Page number must be 1 or greater.'); + } + + $this->pageNumber = $pageNumber; + $this->markdown = $markdown; + $this->images = $images; + $this->dimensions = $dimensions; + } + + /** + * Gets the 1-based page number. + * + * @since n.e.x.t + * + * @return int The page number. + */ + public function getPageNumber(): int + { + return $this->pageNumber; + } + + /** + * Gets the extracted page content as markdown. + * + * Providers that only produce plain text return it unchanged (plain text is valid markdown). + * + * @since n.e.x.t + * + * @return string The page content. + */ + public function getMarkdown(): string + { + return $this->markdown; + } + + /** + * Gets the images embedded in the page. + * + * @since n.e.x.t + * + * @return list The images; empty when not requested or not supported. + */ + public function getImages(): array + { + return $this->images; + } + + /** + * Gets the page dimensions, when reported by the provider. + * + * @since n.e.x.t + * + * @return PageDimensions|null The dimensions, or null if not reported. + */ + public function getDimensions(): ?PageDimensions + { + return $this->dimensions; + } + + /** + * Gets the JSON schema for an extracted page. + * + * @since n.e.x.t + * + * @return array The JSON schema. + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + self::KEY_PAGE_NUMBER => [ + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'The 1-based page number.', + ], + self::KEY_MARKDOWN => [ + 'type' => 'string', + 'description' => 'The extracted page content as markdown.', + ], + self::KEY_IMAGES => [ + 'type' => 'array', + 'items' => ExtractedImage::getJsonSchema(), + 'description' => 'Images embedded in the page.', + ], + self::KEY_DIMENSIONS => PageDimensions::getJsonSchema(), + ], + 'required' => [ + self::KEY_PAGE_NUMBER, + self::KEY_MARKDOWN, + ], + ]; + } + + /** + * Converts the extracted page to an array. + * + * @since n.e.x.t + * + * @return ExtractedPageArrayShape The extracted page array. + */ + public function toArray(): array + { + $data = [ + self::KEY_PAGE_NUMBER => $this->pageNumber, + self::KEY_MARKDOWN => $this->markdown, + ]; + + if (!empty($this->images)) { + $data[self::KEY_IMAGES] = array_map( + static fn (ExtractedImage $image): array => $image->toArray(), + $this->images + ); + } + + if ($this->dimensions !== null) { + $data[self::KEY_DIMENSIONS] = $this->dimensions->toArray(); + } + + return $data; + } + + /** + * Creates an extracted page from an array. + * + * @since n.e.x.t + * + * @param ExtractedPageArrayShape $array The extracted page array. + * @return self The extracted page instance. + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [ + self::KEY_PAGE_NUMBER, + self::KEY_MARKDOWN, + ]); + + $images = []; + if (isset($array[self::KEY_IMAGES])) { + foreach ($array[self::KEY_IMAGES] as $imageData) { + $images[] = ExtractedImage::fromArray($imageData); + } + } + + return new self( + $array[self::KEY_PAGE_NUMBER], + $array[self::KEY_MARKDOWN], + $images, + isset($array[self::KEY_DIMENSIONS]) ? PageDimensions::fromArray($array[self::KEY_DIMENSIONS]) : null + ); + } + + /** + * Creates a deep clone of this extracted page. + * + * @since n.e.x.t + */ + public function __clone() + { + $clonedImages = []; + foreach ($this->images as $image) { + $clonedImages[] = clone $image; + } + $this->images = $clonedImages; + + if ($this->dimensions !== null) { + $this->dimensions = clone $this->dimensions; + } + } +} diff --git a/src/Results/DTO/PageDimensions.php b/src/Results/DTO/PageDimensions.php new file mode 100644 index 00000000..0822d48d --- /dev/null +++ b/src/Results/DTO/PageDimensions.php @@ -0,0 +1,176 @@ + + */ +class PageDimensions extends AbstractDataTransferObject +{ + public const KEY_WIDTH = 'width'; + public const KEY_HEIGHT = 'height'; + public const KEY_DPI = 'dpi'; + + /** + * @var int The page width in pixels. + */ + private int $width; + + /** + * @var int The page height in pixels. + */ + private int $height; + + /** + * @var int|null The resolution in dots per inch, if reported. + */ + private ?int $dpi; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param int $width The page width in pixels. + * @param int $height The page height in pixels. + * @param int|null $dpi The resolution in dots per inch, if reported. + */ + public function __construct(int $width, int $height, ?int $dpi = null) + { + if ($width < 1 || $height < 1) { + throw new InvalidArgumentException('Page dimensions must be positive integers.'); + } + + $this->width = $width; + $this->height = $height; + $this->dpi = $dpi; + } + + /** + * Gets the page width in pixels. + * + * @since n.e.x.t + * + * @return int The width. + */ + public function getWidth(): int + { + return $this->width; + } + + /** + * Gets the page height in pixels. + * + * @since n.e.x.t + * + * @return int The height. + */ + public function getHeight(): int + { + return $this->height; + } + + /** + * Gets the resolution in dots per inch, if reported by the provider. + * + * @since n.e.x.t + * + * @return int|null The DPI, or null if not reported. + */ + public function getDpi(): ?int + { + return $this->dpi; + } + + /** + * Gets the JSON schema for page dimensions. + * + * @since n.e.x.t + * + * @return array The JSON schema. + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + self::KEY_WIDTH => [ + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Page width in pixels.', + ], + self::KEY_HEIGHT => [ + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Page height in pixels.', + ], + self::KEY_DPI => [ + 'type' => 'integer', + 'description' => 'Resolution in dots per inch.', + ], + ], + 'required' => [ + self::KEY_WIDTH, + self::KEY_HEIGHT, + ], + ]; + } + + /** + * Converts the page dimensions to an array. + * + * @since n.e.x.t + * + * @return PageDimensionsArrayShape The page dimensions array. + */ + public function toArray(): array + { + $data = [ + self::KEY_WIDTH => $this->width, + self::KEY_HEIGHT => $this->height, + ]; + + if ($this->dpi !== null) { + $data[self::KEY_DPI] = $this->dpi; + } + + return $data; + } + + /** + * Creates page dimensions from an array. + * + * @since n.e.x.t + * + * @param PageDimensionsArrayShape $array The page dimensions array. + * @return self The page dimensions instance. + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [ + self::KEY_WIDTH, + self::KEY_HEIGHT, + ]); + + return new self( + $array[self::KEY_WIDTH], + $array[self::KEY_HEIGHT], + $array[self::KEY_DPI] ?? null + ); + } +} diff --git a/src/Results/DTO/TextExtractionResult.php b/src/Results/DTO/TextExtractionResult.php new file mode 100644 index 00000000..bab435a2 --- /dev/null +++ b/src/Results/DTO/TextExtractionResult.php @@ -0,0 +1,345 @@ +, + * tokenUsage: TokenUsageArrayShape, + * providerMetadata: ProviderMetadataArrayShape, + * modelMetadata: ModelMetadataArrayShape, + * additionalData?: array + * } + * + * @extends AbstractDataTransferObject + */ +class TextExtractionResult extends AbstractDataTransferObject implements ResultInterface +{ + public const KEY_ID = 'id'; + public const KEY_PAGES = 'pages'; + public const KEY_TOKEN_USAGE = 'tokenUsage'; + public const KEY_PROVIDER_METADATA = 'providerMetadata'; + public const KEY_MODEL_METADATA = 'modelMetadata'; + public const KEY_ADDITIONAL_DATA = 'additionalData'; + + /** + * @var string Unique identifier for this result. + */ + private string $id; + + /** + * @var list The extracted pages. + */ + private array $pages; + + /** + * @var TokenUsage Token usage statistics. + */ + private TokenUsage $tokenUsage; + + /** + * @var ProviderMetadata Provider metadata. + */ + private ProviderMetadata $providerMetadata; + + /** + * @var ModelMetadata Model metadata. + */ + private ModelMetadata $modelMetadata; + + /** + * @var array + */ + private array $additionalData; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param string $id Unique identifier for this result. + * @param list $pages The extracted pages. + * @param TokenUsage $tokenUsage Token usage statistics. Page-priced providers report zeros. + * @param ProviderMetadata $providerMetadata Provider metadata. + * @param ModelMetadata $modelMetadata Model metadata. + * @param array $additionalData Additional data; the raw provider payload + * should be preserved under the `raw` key. + */ + public function __construct( + string $id, + array $pages, + TokenUsage $tokenUsage, + ProviderMetadata $providerMetadata, + ModelMetadata $modelMetadata, + array $additionalData = [] + ) { + if (empty($pages)) { + throw new InvalidArgumentException('At least one extracted page must be provided.'); + } + + foreach ($pages as $page) { + if (!$page instanceof ExtractedPage) { + throw new InvalidArgumentException('All pages must be ExtractedPage instances.'); + } + } + + $this->id = $id; + $this->pages = $pages; + $this->tokenUsage = $tokenUsage; + $this->providerMetadata = $providerMetadata; + $this->modelMetadata = $modelMetadata; + $this->additionalData = $additionalData; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getId(): string + { + return $this->id; + } + + /** + * Gets the extracted pages. + * + * @since n.e.x.t + * + * @return list The pages. + */ + public function getPages(): array + { + return $this->pages; + } + + /** + * Gets the number of pages processed. + * + * For page-priced providers this is the billing-relevant unit. + * + * @since n.e.x.t + * + * @return int The page count. + */ + public function getPageCount(): int + { + return count($this->pages); + } + + /** + * Gets the full extracted content as a single markdown string. + * + * Pages are joined in order, separated by blank lines. + * + * @since n.e.x.t + * + * @return string The extracted content. + */ + public function toMarkdown(): string + { + return implode( + "\n\n", + array_map( + static fn (ExtractedPage $page): string => $page->getMarkdown(), + $this->pages + ) + ); + } + + /** + * Gets the full extracted content as a single string. + * + * Alias of {@see self::toMarkdown()}. + * + * @since n.e.x.t + * + * @return string The extracted content. + */ + public function toText(): string + { + return $this->toMarkdown(); + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getTokenUsage(): TokenUsage + { + return $this->tokenUsage; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getProviderMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getModelMetadata(): ModelMetadata + { + return $this->modelMetadata; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getAdditionalData(): array + { + return $this->additionalData; + } + + /** + * Gets the JSON schema for text extraction results. + * + * @since n.e.x.t + * + * @return array The JSON schema. + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + self::KEY_ID => [ + 'type' => 'string', + 'description' => 'Unique identifier for this result.', + ], + self::KEY_PAGES => [ + 'type' => 'array', + 'items' => ExtractedPage::getJsonSchema(), + 'minItems' => 1, + 'description' => 'The extracted pages.', + ], + self::KEY_TOKEN_USAGE => TokenUsage::getJsonSchema(), + self::KEY_PROVIDER_METADATA => ProviderMetadata::getJsonSchema(), + self::KEY_MODEL_METADATA => ModelMetadata::getJsonSchema(), + self::KEY_ADDITIONAL_DATA => [ + 'type' => 'object', + 'additionalProperties' => true, + 'description' => 'Additional provider-specific data, including the raw provider payload.', + ], + ], + 'required' => [ + self::KEY_ID, + self::KEY_PAGES, + self::KEY_TOKEN_USAGE, + self::KEY_PROVIDER_METADATA, + self::KEY_MODEL_METADATA, + ], + ]; + } + + /** + * Converts the text extraction result to an array. + * + * @since n.e.x.t + * + * @return TextExtractionResultArrayShape The text extraction result array. + */ + public function toArray(): array + { + $data = [ + self::KEY_ID => $this->id, + self::KEY_PAGES => array_map( + static fn (ExtractedPage $page): array => $page->toArray(), + $this->pages + ), + self::KEY_TOKEN_USAGE => $this->tokenUsage->toArray(), + self::KEY_PROVIDER_METADATA => $this->providerMetadata->toArray(), + self::KEY_MODEL_METADATA => $this->modelMetadata->toArray(), + ]; + + if (!empty($this->additionalData)) { + $data[self::KEY_ADDITIONAL_DATA] = $this->additionalData; + } + + return $data; + } + + /** + * Creates a text extraction result from an array. + * + * @since n.e.x.t + * + * @param TextExtractionResultArrayShape $array The text extraction result array. + * @return self The text extraction result instance. + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [ + self::KEY_ID, + self::KEY_PAGES, + self::KEY_TOKEN_USAGE, + self::KEY_PROVIDER_METADATA, + self::KEY_MODEL_METADATA, + ]); + + $pages = []; + foreach ($array[self::KEY_PAGES] as $pageData) { + $pages[] = ExtractedPage::fromArray($pageData); + } + + return new self( + $array[self::KEY_ID], + $pages, + TokenUsage::fromArray($array[self::KEY_TOKEN_USAGE]), + ProviderMetadata::fromArray($array[self::KEY_PROVIDER_METADATA]), + ModelMetadata::fromArray($array[self::KEY_MODEL_METADATA]), + $array[self::KEY_ADDITIONAL_DATA] ?? [] + ); + } + + /** + * Creates a deep clone of this text extraction result. + * + * @since n.e.x.t + */ + public function __clone() + { + $clonedPages = []; + foreach ($this->pages as $page) { + $clonedPages[] = clone $page; + } + $this->pages = $clonedPages; + + $this->tokenUsage = clone $this->tokenUsage; + $this->providerMetadata = clone $this->providerMetadata; + $this->modelMetadata = clone $this->modelMetadata; + } +} diff --git a/tests/unit/Providers/Models/Enums/CapabilityEnumTest.php b/tests/unit/Providers/Models/Enums/CapabilityEnumTest.php index c0f006da..7e6c7d05 100644 --- a/tests/unit/Providers/Models/Enums/CapabilityEnumTest.php +++ b/tests/unit/Providers/Models/Enums/CapabilityEnumTest.php @@ -40,6 +40,7 @@ protected function getExpectedValues(): array 'MUSIC_GENERATION' => 'music_generation', 'VIDEO_GENERATION' => 'video_generation', 'EMBEDDING_GENERATION' => 'embedding_generation', + 'TEXT_EXTRACTION' => 'text_extraction', 'CHAT_HISTORY' => 'chat_history', ]; } diff --git a/tests/unit/Results/DTO/TextExtractionResultTest.php b/tests/unit/Results/DTO/TextExtractionResultTest.php new file mode 100644 index 00000000..b3374852 --- /dev/null +++ b/tests/unit/Results/DTO/TextExtractionResultTest.php @@ -0,0 +1,158 @@ + ['usage_info' => ['pages_processed' => 2]]] + ); + } + + public function testGetters(): void + { + $result = $this->createTextExtractionResult(); + + $this->assertSame('extraction-result-id', $result->getId()); + $this->assertContainsOnlyInstancesOf(ExtractedPage::class, $result->getPages()); + $this->assertSame(2, $result->getPageCount()); + $this->assertSame(0, $result->getTokenUsage()->getTotalTokens()); + $this->assertSame('mock', $result->getProviderMetadata()->getId()); + $this->assertSame('mock-ocr-model', $result->getModelMetadata()->getId()); + $this->assertArrayHasKey('raw', $result->getAdditionalData()); + } + + public function testPageGetters(): void + { + $page = $this->createTextExtractionResult()->getPages()[0]; + + $this->assertSame(1, $page->getPageNumber()); + $this->assertSame("# Heading\n\nFirst page.", $page->getMarkdown()); + $this->assertCount(1, $page->getImages()); + $this->assertNotNull($page->getDimensions()); + $this->assertSame(1700, $page->getDimensions()->getWidth()); + $this->assertSame(2200, $page->getDimensions()->getHeight()); + $this->assertSame(200, $page->getDimensions()->getDpi()); + + $image = $page->getImages()[0]; + $this->assertSame('img-0.jpeg', $image->getId()); + $this->assertNotNull($image->getFile()); + $this->assertNotNull($image->getBoundingBox()); + $this->assertSame(0.17, $image->getBoundingBox()->getLeft()); + $this->assertSame(0.1, $image->getBoundingBox()->getTop()); + $this->assertSame(0.65, $image->getBoundingBox()->getWidth()); + $this->assertSame(0.2, $image->getBoundingBox()->getHeight()); + } + + public function testToMarkdownJoinsPagesInOrder(): void + { + $result = $this->createTextExtractionResult(); + + $this->assertSame("# Heading\n\nFirst page.\n\nSecond page.", $result->toMarkdown()); + $this->assertSame($result->toMarkdown(), $result->toText()); + } + + public function testToArrayFromArrayRoundtrip(): void + { + $result = $this->createTextExtractionResult(); + + $roundtripped = TextExtractionResult::fromArray($result->toArray()); + + $this->assertEquals($result->toArray(), $roundtripped->toArray()); + } + + public function testConstructorRejectsEmptyPages(): void + { + $this->expectException(InvalidArgumentException::class); + + new TextExtractionResult( + 'id', + [], + new TokenUsage(0, 0, 0), + new ProviderMetadata('mock', 'Mock Provider', ProviderTypeEnum::cloud()), + new ModelMetadata('mock-ocr-model', 'Mock OCR Model', [], []) + ); + } + + public function testExtractedPageRejectsZeroPageNumber(): void + { + $this->expectException(InvalidArgumentException::class); + + new ExtractedPage(0, 'content'); + } + + public function testBoundingBoxRejectsOutOfRangeValues(): void + { + $this->expectException(InvalidArgumentException::class); + + new BoundingBox(0.0, 0.0, 1.5, 0.5); + } + + public function testDeepClone(): void + { + $result = $this->createTextExtractionResult(); + $clone = clone $result; + + $this->assertEquals($result->toArray(), $clone->toArray()); + $this->assertNotSame($result->getPages()[0], $clone->getPages()[0]); + $this->assertNotSame( + $result->getPages()[0]->getImages()[0], + $clone->getPages()[0]->getImages()[0] + ); + } + + public function testJsonSchemaListsRequiredKeys(): void + { + $schema = TextExtractionResult::getJsonSchema(); + + $this->assertSame('object', $schema['type']); + $this->assertContains(TextExtractionResult::KEY_ID, $schema['required']); + $this->assertContains(TextExtractionResult::KEY_PAGES, $schema['required']); + } +} From bd3abcfc84605b66bbe6251f46f225712bf52533 Mon Sep 17 00:00:00 2001 From: Lauri Saarni Date: Mon, 3 Aug 2026 14:03:03 +0300 Subject: [PATCH 2/3] Improve the coverage for the text extraction feature --- tests/traits/MockModelCreationTrait.php | 114 ++++++ tests/unit/AiClientTest.php | 80 ++++ .../Builders/TextExtractionBuilderTest.php | 382 ++++++++++++++++++ .../Models/DTO/ModelRequirementsTest.php | 73 ++++ .../Results/DTO/TextExtractionResultTest.php | 23 ++ 5 files changed, 672 insertions(+) create mode 100644 tests/unit/Builders/TextExtractionBuilderTest.php diff --git a/tests/traits/MockModelCreationTrait.php b/tests/traits/MockModelCreationTrait.php index e4fb2c80..94dca9d9 100644 --- a/tests/traits/MockModelCreationTrait.php +++ b/tests/traits/MockModelCreationTrait.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Tests\traits; +use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\Enums\ModalityEnum; @@ -17,12 +18,15 @@ use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\Enums\OptionEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; +use WordPress\AiClient\Providers\Models\TextExtraction\Contracts\TextExtractionModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Providers\Models\VideoGeneration\Contracts\VideoGenerationModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\EmbeddingResult; +use WordPress\AiClient\Results\DTO\ExtractedPage; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Results\DTO\TextExtractionResult; use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; use WordPress\AiClient\Tests\mocks\MockProvider; @@ -201,6 +205,116 @@ protected function createTestEmbeddingModelMetadata( ); } + /** + * Creates a test model metadata instance for text extraction. + * + * @param string $id Optional model ID. + * @param string $name Optional model name. + * @return ModelMetadata + */ + protected function createTestTextExtractionModelMetadata( + string $id = 'test-text-extraction-model', + string $name = 'Test Text Extraction Model' + ): ModelMetadata { + return new ModelMetadata( + $id, + $name, + [CapabilityEnum::textExtraction()], + [] + ); + } + + /** + * Creates a test TextExtractionResult for testing purposes. + * + * @param list|null $pageContents Optional markdown content, one entry per page. + * @return TextExtractionResult + */ + protected function createTestTextExtractionResult(?array $pageContents = null): TextExtractionResult + { + $pageContents = $pageContents ?? ['# Extracted content']; + + $pages = []; + foreach (array_values($pageContents) as $index => $markdown) { + $pages[] = new ExtractedPage($index + 1, $markdown); + } + + return new TextExtractionResult( + 'test-extraction-result-id', + $pages, + new TokenUsage(0, 0, 0), + new ProviderMetadata('mock', 'Mock Provider', ProviderTypeEnum::cloud()), + $this->createTestTextExtractionModelMetadata() + ); + } + + /** + * Creates a mock text extraction model using anonymous class. + * + * @param TextExtractionResult $result The result to return from extraction. + * @param ModelMetadata|null $metadata Optional metadata (uses default if not provided). + * @return ModelInterface&TextExtractionModelInterface The mock model. + */ + protected function createMockTextExtractionModel( + TextExtractionResult $result, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->createTestTextExtractionModelMetadata(); + + $providerMetadata = new ProviderMetadata( + 'mock', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class ( + $metadata, + $providerMetadata, + $result + ) implements ModelInterface, TextExtractionModelInterface { + private ModelMetadata $metadata; + private ProviderMetadata $providerMetadata; + private TextExtractionResult $result; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $providerMetadata, + TextExtractionResult $result + ) { + $this->metadata = $metadata; + $this->providerMetadata = $providerMetadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + public function getConfig(): ModelConfig + { + return $this->config; + } + + public function extractTextResult(File $document): TextExtractionResult + { + return $this->result; + } + }; + } + /** * Creates a mock text generation model using anonymous class. * diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index c19c882d..5b1459df 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -8,6 +8,7 @@ use RuntimeException; use WordPress\AiClient\AiClient; use WordPress\AiClient\Builders\EmbeddingBuilder; +use WordPress\AiClient\Builders\TextExtractionBuilder; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; @@ -306,6 +307,85 @@ public function testInputReturnsEmbeddingBuilder(): void $this->assertCount(2, $result); } + /** + * Tests extractTextResult with document string and provided model. + */ + public function testExtractTextResultWithDocumentAndModel(): void + { + $expectedResult = $this->createTestTextExtractionResult(); + $mockModel = $this->createMockTextExtractionModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); + + $result = AiClient::extractTextResult('https://example.com/document.pdf', $mockModel, $registry); + + $this->assertSame($expectedResult, $result); + } + + /** + * Tests extractText returns the extracted content as a markdown string. + */ + public function testExtractTextReturnsMarkdownString(): void + { + $expectedResult = $this->createTestTextExtractionResult(['# Page one', 'Page two.']); + $mockModel = $this->createMockTextExtractionModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); + + $text = AiClient::extractText('https://example.com/document.pdf', $mockModel, $registry); + + $this->assertSame($expectedResult->toMarkdown(), $text); + } + + /** + * Tests extractTextResult throws exception for model without text extraction interface. + */ + public function testExtractTextResultWithInvalidModel(): void + { + $invalidModel = $this->createMockUnsupportedModel('invalid-extraction-model'); + $registry = $this->createRegistryWithMockProvider(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Model "invalid-extraction-model" does not support text extraction.'); + + AiClient::extractTextResult('https://example.com/document.pdf', $invalidModel, $registry); + } + + /** + * Tests extractTextResult rejects an invalid model/config parameter. + */ + public function testExtractTextResultRejectsInvalidModelParameter(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Parameter must be a ModelInterface instance \(specific model\)/'); + + /** @phpstan-ignore-next-line Intentionally passing an invalid type. */ + AiClient::extractTextResult('https://example.com/document.pdf', 'invalid_string_parameter'); + } + + /** + * Tests document() returns a TextExtractionBuilder configured with the given document. + */ + public function testDocumentReturnsTextExtractionBuilder(): void + { + $expectedResult = $this->createTestTextExtractionResult(); + $mockModel = $this->createMockTextExtractionModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); + + $builder = AiClient::document('https://example.com/document.pdf', $registry); + + $this->assertInstanceOf(TextExtractionBuilder::class, $builder); + $this->assertSame($expectedResult, $builder->usingModel($mockModel)->extractTextResult()); + } + + /** + * Tests document() without a document returns a builder that is not yet usable. + */ + public function testDocumentWithoutDocumentReturnsBuilder(): void + { + $builder = AiClient::document(null, $this->createMockEmptyRegistry()); + + $this->assertInstanceOf(TextExtractionBuilder::class, $builder); + $this->assertFalse($builder->isSupported()); + } /** * Tests generateTextResult with Message object. diff --git a/tests/unit/Builders/TextExtractionBuilderTest.php b/tests/unit/Builders/TextExtractionBuilderTest.php new file mode 100644 index 00000000..ee418a12 --- /dev/null +++ b/tests/unit/Builders/TextExtractionBuilderTest.php @@ -0,0 +1,382 @@ +registry = $this->createMock(ProviderRegistry::class); + } + + /** + * Reads the configured document from a builder. + * + * @param TextExtractionBuilder $builder The builder to inspect. + * @return File|null The configured document. + */ + private function getDocument(TextExtractionBuilder $builder): ?File + { + $reflection = new \ReflectionClass($builder); + $property = $reflection->getProperty('document'); + $property->setAccessible(true); + + /** @var File|null $document */ + $document = $property->getValue($builder); + + return $document; + } + + /** + * Reads the model configuration from a builder. + * + * @param TextExtractionBuilder $builder The builder to inspect. + * @return ModelConfig The model configuration. + */ + private function getModelConfig(TextExtractionBuilder $builder): ModelConfig + { + $reflection = new \ReflectionClass($builder); + $property = $reflection->getProperty('modelConfig'); + $property->setAccessible(true); + + /** @var ModelConfig $config */ + $config = $property->getValue($builder); + + return $config; + } + + /** + * Tests the constructor accepts an initial document string. + * + * @return void + */ + public function testConstructorWithDocumentString(): void + { + $builder = new TextExtractionBuilder($this->registry, 'https://example.com/document.pdf'); + + $document = $this->getDocument($builder); + $this->assertInstanceOf(File::class, $document); + $this->assertSame('application/pdf', $document->getMimeType()); + } + + /** + * Tests the constructor without a document leaves it unset. + * + * @return void + */ + public function testConstructorWithoutDocument(): void + { + $builder = new TextExtractionBuilder($this->registry); + + $this->assertNull($this->getDocument($builder)); + } + + /** + * Tests withDocument accepts a File instance. + * + * @return void + */ + public function testWithDocumentAcceptsFile(): void + { + $file = new File('https://example.com/scan.png', 'image/png'); + + $builder = new TextExtractionBuilder($this->registry); + $builder->withDocument($file); + + $this->assertSame($file, $this->getDocument($builder)); + } + + /** + * Tests withDocument uses the explicit MIME type for a string input. + * + * @return void + */ + public function testWithDocumentUsesExplicitMimeType(): void + { + $builder = new TextExtractionBuilder($this->registry); + $builder->withDocument('https://arxiv.org/pdf/1805.04770', 'application/pdf'); + + $document = $this->getDocument($builder); + $this->assertInstanceOf(File::class, $document); + $this->assertSame('application/pdf', $document->getMimeType()); + } + + /** + * Tests withDocument rejects an empty string. + * + * @return void + */ + public function testWithDocumentRejectsEmptyString(): void + { + $builder = new TextExtractionBuilder($this->registry); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot create a document from an empty string.'); + + $builder->withDocument(' '); + } + + /** + * Tests withDocument rejects an unsupported input type. + * + * @return void + */ + public function testWithDocumentRejectsUnsupportedType(): void + { + $builder = new TextExtractionBuilder($this->registry); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Document must be a File instance or a string.'); + + /** @phpstan-ignore-next-line Intentionally passing an invalid type. */ + $builder->withDocument(123); + } + + /** + * Tests extractTextResult returns the model result. + * + * @return void + */ + public function testExtractTextResultWithModel(): void + { + $result = $this->createTestTextExtractionResult(['# Page one']); + $model = $this->createMockTextExtractionModel($result); + + $builder = new TextExtractionBuilder($this->registry, 'https://example.com/document.pdf'); + $builder->usingModel($model); + + $this->assertSame($result, $builder->extractTextResult()); + } + + /** + * Tests extractText joins the extracted pages into a markdown string. + * + * @return void + */ + public function testExtractTextReturnsMarkdown(): void + { + $result = $this->createTestTextExtractionResult(['# Page one', 'Page two.']); + $model = $this->createMockTextExtractionModel($result); + + $builder = new TextExtractionBuilder($this->registry, 'https://example.com/document.pdf'); + $builder->usingModel($model); + + $this->assertSame($result->toMarkdown(), $builder->extractText()); + $this->assertStringContainsString('# Page one', $builder->extractText()); + $this->assertStringContainsString('Page two.', $builder->extractText()); + } + + /** + * Tests extractTextResult throws when no document is configured. + * + * @return void + */ + public function testExtractTextResultThrowsWithoutDocument(): void + { + $builder = new TextExtractionBuilder($this->registry); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot extract text without a document. Add one using withDocument().'); + + $builder->extractTextResult(); + } + + /** + * Tests extractTextResult throws for a model that does not support text extraction. + * + * @return void + */ + public function testExtractTextResultThrowsForUnsupportedModel(): void + { + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + + $model = $this->createMock(ModelInterface::class); + $model->method('metadata')->willReturn($metadata); + + $builder = new TextExtractionBuilder($this->registry, 'https://example.com/document.pdf'); + $builder->usingModel($model); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Model "test-model" does not support text extraction.'); + + $builder->extractTextResult(); + } + + /** + * Tests model selection requires the text extraction capability and document input modality. + * + * @return void + */ + public function testModelSelectionUsesDocumentModality(): void + { + $result = $this->createTestTextExtractionResult(); + $modelMetadata = $this->createTestTextExtractionModelMetadata('ocr-model'); + $model = $this->createMockTextExtractionModel($result, $modelMetadata); + $providerMetadata = new ProviderMetadata('mock', 'Mock Provider', ProviderTypeEnum::cloud()); + + $this->registry->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->with($this->callback(static function (ModelRequirements $requirements): bool { + $hasCapability = false; + foreach ($requirements->getRequiredCapabilities() as $capability) { + if ($capability->isTextExtraction()) { + $hasCapability = true; + } + } + + foreach ($requirements->getRequiredOptions() as $requiredOption) { + if ($requiredOption->getName()->isInputModalities()) { + return $hasCapability && [ModalityEnum::document()] == $requiredOption->getValue(); + } + } + + return false; + })) + ->willReturn([new ProviderModelsMetadata($providerMetadata, [$modelMetadata])]); + + $this->registry->expects($this->once()) + ->method('getProviderModel') + ->with('mock', 'ocr-model', $this->isInstanceOf(ModelConfig::class)) + ->willReturn($model); + + $builder = new TextExtractionBuilder($this->registry, 'https://example.com/document.pdf'); + + $this->assertSame($result, $builder->extractTextResult()); + } + + /** + * Tests model selection uses the image input modality for image documents. + * + * @return void + */ + public function testModelSelectionUsesImageModalityForImages(): void + { + $result = $this->createTestTextExtractionResult(); + $modelMetadata = $this->createTestTextExtractionModelMetadata('ocr-model'); + $model = $this->createMockTextExtractionModel($result, $modelMetadata); + $providerMetadata = new ProviderMetadata('mock', 'Mock Provider', ProviderTypeEnum::cloud()); + + $this->registry->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->with($this->callback(static function (ModelRequirements $requirements): bool { + foreach ($requirements->getRequiredOptions() as $requiredOption) { + if ($requiredOption->getName()->isInputModalities()) { + return [ModalityEnum::image()] == $requiredOption->getValue(); + } + } + + return false; + })) + ->willReturn([new ProviderModelsMetadata($providerMetadata, [$modelMetadata])]); + + $this->registry->method('getProviderModel')->willReturn($model); + + $builder = new TextExtractionBuilder($this->registry, 'https://example.com/scan.png'); + + $this->assertSame($result, $builder->extractTextResult()); + } + + /** + * Tests isSupported returns false when no document is configured. + * + * @return void + */ + public function testIsSupportedReturnsFalseWithoutDocument(): void + { + $builder = new TextExtractionBuilder($this->registry); + + $this->assertFalse($builder->isSupported()); + } + + /** + * Tests isSupported returns false when no suitable models exist. + * + * @return void + */ + public function testIsSupportedReturnsFalseWhenNoModels(): void + { + $this->registry->method('findModelsMetadataForSupport')->willReturn([]); + + $builder = new TextExtractionBuilder($this->registry, 'https://example.com/document.pdf'); + + $this->assertFalse($builder->isSupported()); + } + + /** + * Tests isSupported returns true when a suitable model exists. + * + * @return void + */ + public function testIsSupportedReturnsTrueWhenModelAvailable(): void + { + $modelMetadata = $this->createTestTextExtractionModelMetadata(); + $providerMetadata = new ProviderMetadata('mock', 'Mock Provider', ProviderTypeEnum::cloud()); + + $this->registry->method('findModelsMetadataForSupport') + ->willReturn([new ProviderModelsMetadata($providerMetadata, [$modelMetadata])]); + + $builder = new TextExtractionBuilder($this->registry, 'https://example.com/document.pdf'); + + $this->assertTrue($builder->isSupported()); + } + + /** + * Tests cloning deep-copies the document and configuration. + * + * @return void + */ + public function testCloneDeepCopiesDocumentAndConfig(): void + { + $original = new TextExtractionBuilder($this->registry, 'https://example.com/document.pdf'); + + $cloned = clone $original; + + $this->assertNotSame($this->getDocument($original), $this->getDocument($cloned)); + $this->assertNotSame($this->getModelConfig($original), $this->getModelConfig($cloned)); + } + + /** + * Tests cloning a builder without a document. + * + * @return void + */ + public function testCloneWithoutDocument(): void + { + $original = new TextExtractionBuilder($this->registry); + + $cloned = clone $original; + + $this->assertNull($this->getDocument($cloned)); + $this->assertNotSame($this->getModelConfig($original), $this->getModelConfig($cloned)); + } +} diff --git a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php index 16efdddd..4b1bf59d 100644 --- a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php +++ b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php @@ -832,4 +832,77 @@ public function testFromPromptDataWithModelConfigOptions(): void $this->assertTrue($hasTopP, 'Top P option should be present'); $this->assertTrue($hasDimensions, 'Dimensions option should be present'); } + + /** + * Tests fromExtractionData method with a document file. + * + * @return void + */ + public function testFromExtractionDataWithDocument(): void + { + $document = new File('https://example.com/document.pdf', 'application/pdf'); + + $requirements = ModelRequirements::fromExtractionData($document, new ModelConfig()); + + $this->assertEquals([CapabilityEnum::textExtraction()], $requirements->getRequiredCapabilities()); + + $inputModalityOptions = array_filter( + $requirements->getRequiredOptions(), + fn($opt) => $opt->getName()->isInputModalities() + ); + $this->assertNotEmpty($inputModalityOptions); + + $modalityValues = array_values($inputModalityOptions)[0]->getValue(); + $this->assertEquals([ModalityEnum::document()], $modalityValues); + } + + /** + * Tests fromExtractionData method with an image file. + * + * @return void + */ + public function testFromExtractionDataWithImage(): void + { + $image = new File('https://example.com/scan.png', 'image/png'); + + $requirements = ModelRequirements::fromExtractionData($image, new ModelConfig()); + + $this->assertEquals([CapabilityEnum::textExtraction()], $requirements->getRequiredCapabilities()); + + $inputModalityOptions = array_filter( + $requirements->getRequiredOptions(), + fn($opt) => $opt->getName()->isInputModalities() + ); + $this->assertNotEmpty($inputModalityOptions); + + $modalityValues = array_values($inputModalityOptions)[0]->getValue(); + $this->assertEquals([ModalityEnum::image()], $modalityValues); + } + + /** + * Tests fromExtractionData method includes extraction options from the model configuration. + * + * Extraction options such as page selection are provider-specific and travel as + * custom options on the model configuration. + * + * @return void + */ + public function testFromExtractionDataWithModelConfigOptions(): void + { + $document = new File('https://example.com/document.pdf', 'application/pdf'); + $modelConfig = new ModelConfig(); + $modelConfig->setCustomOption('pages', [0, 1]); + + $requirements = ModelRequirements::fromExtractionData($document, $modelConfig); + + $hasPagesOption = false; + foreach ($requirements->getRequiredOptions() as $option) { + if ($option->getName()->isCustomOptions()) { + $hasPagesOption = true; + $this->assertEquals(['pages' => [0, 1]], $option->getValue()); + } + } + + $this->assertTrue($hasPagesOption, 'Custom pages option should be present'); + } } diff --git a/tests/unit/Results/DTO/TextExtractionResultTest.php b/tests/unit/Results/DTO/TextExtractionResultTest.php index b3374852..6adb4fce 100644 --- a/tests/unit/Results/DTO/TextExtractionResultTest.php +++ b/tests/unit/Results/DTO/TextExtractionResultTest.php @@ -120,6 +120,29 @@ public function testConstructorRejectsEmptyPages(): void ); } + public function testConstructorRejectsNonExtractedPageEntries(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All pages must be ExtractedPage instances.'); + + new TextExtractionResult( + 'id', + /** @phpstan-ignore-next-line Intentionally passing invalid page entries. */ + ['not a page'], + new TokenUsage(0, 0, 0), + new ProviderMetadata('mock', 'Mock Provider', ProviderTypeEnum::cloud()), + new ModelMetadata('mock-ocr-model', 'Mock OCR Model', [], []) + ); + } + + public function testPageDimensionsRejectsNonPositiveValues(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Page dimensions must be positive integers.'); + + new PageDimensions(0, 2200); + } + public function testExtractedPageRejectsZeroPageNumber(): void { $this->expectException(InvalidArgumentException::class); From 162b40c316a4ac4ac72e0e5a2d1b11d5a7acdc89 Mon Sep 17 00:00:00 2001 From: Lauri Saarni Date: Wed, 2 Sep 2026 13:32:27 +0300 Subject: [PATCH 3/3] Address review feedback on text extraction Correctness: - Reject file types text extraction cannot consume. ModelRequirements mapped any non-image file to the document modality, so an audio or video file was silently required as `document` and surfaced later as a confusing "no model found" error or a provider-side failure. The file-to-modality mapping is now shared with inputModalityForPart() via modalityForFile(), and TextExtractionBuilder::withDocument() rejects unsupported types up front with a message naming the type. - Cast integer fields in PageDimensions::fromArray() and ExtractedPage::fromArray(). Under strict_types a JSON payload carrying 1.0 raised an uncaught TypeError, which also escapes isArrayShape()'s InvalidArgumentException contract. - Validate that ExtractedPage images are ExtractedImage instances, matching how TextExtractionResult validates its pages. API: - Dispatch BeforeExtractTextEvent / AfterExtractTextEvent around extraction, as specified in #268, and forward the shared dispatcher from AiClient::document(). Listeners were previously blind to extraction calls. - Take pageCount as an optional constructor argument, defaulting to the number of returned pages. It was derived from count($pages), so it could not carry a provider's reported pages_processed, which is the only billing signal in the result for page-priced providers. - Drop TextExtractionResult::toText(). It was an alias of toMarkdown() and invited confusion with GenerativeAiResult::toText(), which filters instead. - Drop the empty-string guard from withDocument() so that File stays the single validator of its own input, consistent with PromptBuilder::withFile() and MessageBuilder::withFile(). Co-Authored-By: Claude Opus 5 (1M context) --- src/AiClient.php | 3 +- src/Builders/TextExtractionBuilder.php | 63 ++++++++-- src/Events/AfterExtractTextEvent.php | 119 ++++++++++++++++++ src/Events/BeforeExtractTextEvent.php | 94 ++++++++++++++ .../Models/DTO/ModelRequirements.php | 80 ++++++++++-- src/Results/DTO/ExtractedPage.php | 8 +- src/Results/DTO/PageDimensions.php | 6 +- src/Results/DTO/TextExtractionResult.php | 57 +++++---- .../Builders/TextExtractionBuilderTest.php | 92 +++++++++++++- .../Models/DTO/ModelRequirementsTest.php | 78 ++++++++++++ .../Results/DTO/TextExtractionResultTest.php | 102 ++++++++++++++- 11 files changed, 653 insertions(+), 49 deletions(-) create mode 100644 src/Events/AfterExtractTextEvent.php create mode 100644 src/Events/BeforeExtractTextEvent.php diff --git a/src/AiClient.php b/src/AiClient.php index 168b8c47..afbe3ec8 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -294,7 +294,8 @@ public static function document($document = null, ?ProviderRegistry $registry = { return new TextExtractionBuilder( $registry ?? self::defaultRegistry(), - $document + $document, + self::$eventDispatcher ); } diff --git a/src/Builders/TextExtractionBuilder.php b/src/Builders/TextExtractionBuilder.php index 7c1a0b77..e523d4b1 100644 --- a/src/Builders/TextExtractionBuilder.php +++ b/src/Builders/TextExtractionBuilder.php @@ -4,13 +4,17 @@ namespace WordPress\AiClient\Builders; +use Psr\EventDispatcher\EventDispatcherInterface; use WordPress\AiClient\Builders\Traits\ModelResolutionTrait; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; +use WordPress\AiClient\Events\AfterExtractTextEvent; +use WordPress\AiClient\Events\BeforeExtractTextEvent; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Providers\ModelResolver; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; +use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\TextExtraction\Contracts\TextExtractionModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\TextExtractionResult; @@ -35,6 +39,11 @@ class TextExtractionBuilder */ protected ?File $document = null; + /** + * @var EventDispatcherInterface|null The event dispatcher for extraction lifecycle events. + */ + private ?EventDispatcherInterface $eventDispatcher; + /** * Constructor. * @@ -42,11 +51,16 @@ class TextExtractionBuilder * * @param ProviderRegistry $registry The provider registry for finding suitable models. * @param DocumentInput|null $document Optional initial document to extract text from. + * @param EventDispatcherInterface|null $eventDispatcher Optional event dispatcher for lifecycle events. */ - public function __construct(ProviderRegistry $registry, $document = null) - { + public function __construct( + ProviderRegistry $registry, + $document = null, + ?EventDispatcherInterface $eventDispatcher = null + ) { $this->modelConfig = new ModelConfig(); $this->modelResolver = new ModelResolver($registry); + $this->eventDispatcher = $eventDispatcher; if ($document !== null) { $this->withDocument($document); @@ -56,8 +70,8 @@ public function __construct(ProviderRegistry $registry, $document = null) /** * Creates a deep clone of this builder. * - * Clones the document and model configuration. Service objects are intentionally NOT cloned - * as they are shared dependencies. + * Clones the document and model configuration. Service objects (resolver, event dispatcher) + * are intentionally NOT cloned as they are shared dependencies. * * @since n.e.x.t */ @@ -83,14 +97,12 @@ public function __clone() * URL such as `https://arxiv.org/pdf/1805.04770`). Ignored * when a File instance is given. * @return self - * @throws InvalidArgumentException If the document input is invalid. + * @throws InvalidArgumentException If the document input is invalid, or if its MIME type is + * not supported for text extraction. */ public function withDocument($document, ?string $mimeType = null): self { if (is_string($document)) { - if (trim($document) === '') { - throw new InvalidArgumentException('Cannot create a document from an empty string.'); - } $document = new File($document, $mimeType); } @@ -98,6 +110,11 @@ public function withDocument($document, ?string $mimeType = null): self throw new InvalidArgumentException('Document must be a File instance or a string.'); } + // Reject unsupported MIME types here, where the file is still the caller's own input, so + // that the error names the offending type instead of surfacing later as a resolution or + // provider failure. + ModelRequirements::extractionInputModality($document); + $this->document = $document; return $this; @@ -132,13 +149,16 @@ public function isSupported(): bool */ public function extractTextResult(): TextExtractionResult { - if ($this->document === null) { + $document = $this->document; + + if ($document === null) { throw new InvalidArgumentException( 'Cannot extract text without a document. Add one using withDocument().' ); } - $requirements = ModelRequirements::fromExtractionData($this->document, $this->modelConfig); + $capability = CapabilityEnum::textExtraction(); + $requirements = ModelRequirements::fromExtractionData($document, $this->modelConfig); $model = $this->modelResolver->resolve($requirements, $this->modelConfig); if (!$model instanceof TextExtractionModelInterface) { @@ -150,7 +170,13 @@ public function extractTextResult(): TextExtractionResult ); } - return $model->extractTextResult($this->document); + $this->dispatchEvent(new BeforeExtractTextEvent($document, $model, $capability)); + + $result = $model->extractTextResult($document); + + $this->dispatchEvent(new AfterExtractTextEvent($document, $model, $capability, $result)); + + return $result; } /** @@ -166,4 +192,19 @@ public function extractText(): string { return $this->extractTextResult()->toMarkdown(); } + + /** + * Dispatches an event if an event dispatcher is registered. + * + * @since n.e.x.t + * + * @param object $event The event to dispatch. + * @return void + */ + private function dispatchEvent(object $event): void + { + if ($this->eventDispatcher !== null) { + $this->eventDispatcher->dispatch($event); + } + } } diff --git a/src/Events/AfterExtractTextEvent.php b/src/Events/AfterExtractTextEvent.php new file mode 100644 index 00000000..3d712257 --- /dev/null +++ b/src/Events/AfterExtractTextEvent.php @@ -0,0 +1,119 @@ +document = $document; + $this->model = $model; + $this->capability = $capability; + $this->result = $result; + } + + /** + * Gets the document that was sent to the model. + * + * @since n.e.x.t + * + * @return File The document. + */ + public function getDocument(): File + { + return $this->document; + } + + /** + * Gets the model that extracted text. + * + * @since n.e.x.t + * + * @return ModelInterface The model. + */ + public function getModel(): ModelInterface + { + return $this->model; + } + + /** + * Gets the capability that was used for extraction. + * + * @since n.e.x.t + * + * @return CapabilityEnum The capability. + */ + public function getCapability(): CapabilityEnum + { + return $this->capability; + } + + /** + * Gets the result from the model. + * + * @since n.e.x.t + * + * @return TextExtractionResult The result. + */ + public function getResult(): TextExtractionResult + { + return $this->result; + } + + /** + * Performs a deep clone of the event. + * + * @since n.e.x.t + */ + public function __clone() + { + $this->document = clone $this->document; + $this->result = clone $this->result; + } +} diff --git a/src/Events/BeforeExtractTextEvent.php b/src/Events/BeforeExtractTextEvent.php new file mode 100644 index 00000000..f78a386e --- /dev/null +++ b/src/Events/BeforeExtractTextEvent.php @@ -0,0 +1,94 @@ +document = $document; + $this->model = $model; + $this->capability = $capability; + } + + /** + * Gets the document to be sent to the model. + * + * @since n.e.x.t + * + * @return File The document. + */ + public function getDocument(): File + { + return $this->document; + } + + /** + * Gets the model that will extract text. + * + * @since n.e.x.t + * + * @return ModelInterface The model. + */ + public function getModel(): ModelInterface + { + return $this->model; + } + + /** + * Gets the capability being used for extraction. + * + * @since n.e.x.t + * + * @return CapabilityEnum The capability. + */ + public function getCapability(): CapabilityEnum + { + return $this->capability; + } + + /** + * Performs a deep clone of the event. + * + * @since n.e.x.t + */ + public function __clone() + { + $this->document = clone $this->document; + } +} diff --git a/src/Providers/Models/DTO/ModelRequirements.php b/src/Providers/Models/DTO/ModelRequirements.php index f35a8777..b1020248 100644 --- a/src/Providers/Models/DTO/ModelRequirements.php +++ b/src/Providers/Models/DTO/ModelRequirements.php @@ -279,6 +279,8 @@ public static function fromEmbeddingData(array $inputs, ModelConfig $modelConfig * @param File $document The document to extract text from. * @param ModelConfig $modelConfig The model configuration. * @return self The created requirements. + * + * @throws InvalidArgumentException If the file's MIME type is not supported for text extraction. */ public static function fromExtractionData(File $document, ModelConfig $modelConfig): self { @@ -286,7 +288,7 @@ public static function fromExtractionData(File $document, ModelConfig $modelConf $requiredOptions = self::toRequiredOptions($modelConfig); - $inputModality = $document->isImage() ? ModalityEnum::image() : ModalityEnum::document(); + $inputModality = self::extractionInputModality($document); $requiredOptions = self::includeInRequiredOptions( $requiredOptions, new RequiredOption(OptionEnum::inputModalities(), [$inputModality]) @@ -295,6 +297,40 @@ public static function fromExtractionData(File $document, ModelConfig $modelConf return new self($capabilities, $requiredOptions); } + /** + * Determines the input modality required to extract text from the given file. + * + * Text extraction only accepts modalities that carry readable content, so audio, video, and + * files of an unrecognized type are rejected here rather than being mapped to a modality they + * do not match. Mapping them to `document` would either surface as an unhelpful "no model + * found" error or select a model that then fails provider-side. + * + * Exposed separately from {@see self::fromExtractionData()} so that callers can validate a + * file, or declare the modality a model must support, without building full requirements. + * + * @since n.e.x.t + * + * @param File $file The file to analyze. + * @return ModalityEnum The required input modality. + * + * @throws InvalidArgumentException If the file's MIME type is not supported for text extraction. + */ + public static function extractionInputModality(File $file): ModalityEnum + { + $modality = self::modalityForFile($file); + + if ($modality === null || !($modality->isImage() || $modality->isDocument())) { + throw new InvalidArgumentException( + sprintf( + 'Text extraction supports image and document files, got "%s".', + $file->getMimeType() + ) + ); + } + + return $modality; + } + /** * Determines the input modality contributed by a message part, if any. * @@ -315,21 +351,45 @@ private static function inputModalityForPart(MessagePart $part): ?ModalityEnum $file = $part->getFile(); if ($file !== null) { - if ($file->isImage()) { - return ModalityEnum::image(); - } elseif ($file->isAudio()) { - return ModalityEnum::audio(); - } elseif ($file->isVideo()) { - return ModalityEnum::video(); - } elseif ($file->isDocument() || $file->isText()) { - return ModalityEnum::document(); - } + return self::modalityForFile($file); } } return null; } + /** + * Determines the input modality contributed by a file, if any. + * + * Text files are treated as documents, since they carry document-shaped content rather than + * a text prompt. + * + * @since n.e.x.t + * + * @param File $file The file to analyze. + * @return ModalityEnum|null The input modality, or null if the MIME type is not recognized. + */ + private static function modalityForFile(File $file): ?ModalityEnum + { + if ($file->isImage()) { + return ModalityEnum::image(); + } + + if ($file->isAudio()) { + return ModalityEnum::audio(); + } + + if ($file->isVideo()) { + return ModalityEnum::video(); + } + + if ($file->isDocument() || $file->isText()) { + return ModalityEnum::document(); + } + + return null; + } + /** * Converts ModelConfig to an array of RequiredOptions. * diff --git a/src/Results/DTO/ExtractedPage.php b/src/Results/DTO/ExtractedPage.php index ca18ef13..91663602 100644 --- a/src/Results/DTO/ExtractedPage.php +++ b/src/Results/DTO/ExtractedPage.php @@ -71,6 +71,12 @@ public function __construct( throw new InvalidArgumentException('Page number must be 1 or greater.'); } + foreach ($images as $image) { + if (!$image instanceof ExtractedImage) { + throw new InvalidArgumentException('All images must be ExtractedImage instances.'); + } + } + $this->pageNumber = $pageNumber; $this->markdown = $markdown; $this->images = $images; @@ -213,7 +219,7 @@ public static function fromArray(array $array): self } return new self( - $array[self::KEY_PAGE_NUMBER], + (int) $array[self::KEY_PAGE_NUMBER], $array[self::KEY_MARKDOWN], $images, isset($array[self::KEY_DIMENSIONS]) ? PageDimensions::fromArray($array[self::KEY_DIMENSIONS]) : null diff --git a/src/Results/DTO/PageDimensions.php b/src/Results/DTO/PageDimensions.php index 0822d48d..9e82f385 100644 --- a/src/Results/DTO/PageDimensions.php +++ b/src/Results/DTO/PageDimensions.php @@ -168,9 +168,9 @@ public static function fromArray(array $array): self ]); return new self( - $array[self::KEY_WIDTH], - $array[self::KEY_HEIGHT], - $array[self::KEY_DPI] ?? null + (int) $array[self::KEY_WIDTH], + (int) $array[self::KEY_HEIGHT], + isset($array[self::KEY_DPI]) ? (int) $array[self::KEY_DPI] : null ); } } diff --git a/src/Results/DTO/TextExtractionResult.php b/src/Results/DTO/TextExtractionResult.php index bab435a2..b235b988 100644 --- a/src/Results/DTO/TextExtractionResult.php +++ b/src/Results/DTO/TextExtractionResult.php @@ -15,8 +15,9 @@ * * Unlike {@see GenerativeAiResult}, extraction results are not candidate-based: they hold the * structured, per-page content of a processed document. Providers that bill per page report a - * zero {@see TokenUsage}; the meaningful unit is {@see self::getPageCount()}. The raw decoded - * provider payload should be preserved under the `raw` key of the additional data. + * zero {@see TokenUsage}; the meaningful unit is {@see self::getPageCount()}, which is the only + * billing signal such providers put in the result. The raw decoded provider payload should be + * preserved under the `raw` key of the additional data. * * @since n.e.x.t * @@ -28,6 +29,7 @@ * @phpstan-type TextExtractionResultArrayShape array{ * id: string, * pages: list, + * pageCount: int, * tokenUsage: TokenUsageArrayShape, * providerMetadata: ProviderMetadataArrayShape, * modelMetadata: ModelMetadataArrayShape, @@ -40,6 +42,7 @@ class TextExtractionResult extends AbstractDataTransferObject implements ResultI { public const KEY_ID = 'id'; public const KEY_PAGES = 'pages'; + public const KEY_PAGE_COUNT = 'pageCount'; public const KEY_TOKEN_USAGE = 'tokenUsage'; public const KEY_PROVIDER_METADATA = 'providerMetadata'; public const KEY_MODEL_METADATA = 'modelMetadata'; @@ -55,6 +58,11 @@ class TextExtractionResult extends AbstractDataTransferObject implements ResultI */ private array $pages; + /** + * @var int The number of pages the provider processed. + */ + private int $pageCount; + /** * @var TokenUsage Token usage statistics. */ @@ -87,6 +95,10 @@ class TextExtractionResult extends AbstractDataTransferObject implements ResultI * @param ModelMetadata $modelMetadata Model metadata. * @param array $additionalData Additional data; the raw provider payload * should be preserved under the `raw` key. + * @param int|null $pageCount The number of pages the provider reports having processed. Pass + * the provider's own count when it reports one (e.g. Mistral's + * `usage_info.pages_processed`), since it can differ from the + * number of returned pages. Defaults to the number of pages given. */ public function __construct( string $id, @@ -94,7 +106,8 @@ public function __construct( TokenUsage $tokenUsage, ProviderMetadata $providerMetadata, ModelMetadata $modelMetadata, - array $additionalData = [] + array $additionalData = [], + ?int $pageCount = null ) { if (empty($pages)) { throw new InvalidArgumentException('At least one extracted page must be provided.'); @@ -106,8 +119,13 @@ public function __construct( } } + if ($pageCount !== null && $pageCount < 1) { + throw new InvalidArgumentException('Page count must be 1 or greater.'); + } + $this->id = $id; $this->pages = $pages; + $this->pageCount = $pageCount ?? count($pages); $this->tokenUsage = $tokenUsage; $this->providerMetadata = $providerMetadata; $this->modelMetadata = $modelMetadata; @@ -137,9 +155,12 @@ public function getPages(): array } /** - * Gets the number of pages processed. + * Gets the number of pages the provider processed. * - * For page-priced providers this is the billing-relevant unit. + * For page-priced providers this is the billing-relevant unit, and it can exceed the number + * of pages returned by {@see self::getPages()} (for example when a page range was requested + * but the provider bills for the whole document). Falls back to the number of returned pages + * when the provider reports no count of its own. * * @since n.e.x.t * @@ -147,7 +168,7 @@ public function getPages(): array */ public function getPageCount(): int { - return count($this->pages); + return $this->pageCount; } /** @@ -170,20 +191,6 @@ public function toMarkdown(): string ); } - /** - * Gets the full extracted content as a single string. - * - * Alias of {@see self::toMarkdown()}. - * - * @since n.e.x.t - * - * @return string The extracted content. - */ - public function toText(): string - { - return $this->toMarkdown(); - } - /** * {@inheritDoc} * @@ -246,6 +253,11 @@ public static function getJsonSchema(): array 'minItems' => 1, 'description' => 'The extracted pages.', ], + self::KEY_PAGE_COUNT => [ + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'The number of pages the provider processed.', + ], self::KEY_TOKEN_USAGE => TokenUsage::getJsonSchema(), self::KEY_PROVIDER_METADATA => ProviderMetadata::getJsonSchema(), self::KEY_MODEL_METADATA => ModelMetadata::getJsonSchema(), @@ -258,6 +270,7 @@ public static function getJsonSchema(): array 'required' => [ self::KEY_ID, self::KEY_PAGES, + self::KEY_PAGE_COUNT, self::KEY_TOKEN_USAGE, self::KEY_PROVIDER_METADATA, self::KEY_MODEL_METADATA, @@ -280,6 +293,7 @@ public function toArray(): array static fn (ExtractedPage $page): array => $page->toArray(), $this->pages ), + self::KEY_PAGE_COUNT => $this->pageCount, self::KEY_TOKEN_USAGE => $this->tokenUsage->toArray(), self::KEY_PROVIDER_METADATA => $this->providerMetadata->toArray(), self::KEY_MODEL_METADATA => $this->modelMetadata->toArray(), @@ -321,7 +335,8 @@ public static function fromArray(array $array): self TokenUsage::fromArray($array[self::KEY_TOKEN_USAGE]), ProviderMetadata::fromArray($array[self::KEY_PROVIDER_METADATA]), ModelMetadata::fromArray($array[self::KEY_MODEL_METADATA]), - $array[self::KEY_ADDITIONAL_DATA] ?? [] + $array[self::KEY_ADDITIONAL_DATA] ?? [], + isset($array[self::KEY_PAGE_COUNT]) ? (int) $array[self::KEY_PAGE_COUNT] : null ); } diff --git a/tests/unit/Builders/TextExtractionBuilderTest.php b/tests/unit/Builders/TextExtractionBuilderTest.php index ee418a12..48120cdc 100644 --- a/tests/unit/Builders/TextExtractionBuilderTest.php +++ b/tests/unit/Builders/TextExtractionBuilderTest.php @@ -5,9 +5,12 @@ namespace WordPress\AiClient\Tests\unit\Builders; use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; use WordPress\AiClient\Builders\TextExtractionBuilder; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; +use WordPress\AiClient\Events\AfterExtractTextEvent; +use WordPress\AiClient\Events\BeforeExtractTextEvent; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\DTO\ProviderMetadata; @@ -139,12 +142,44 @@ public function testWithDocumentRejectsEmptyString(): void { $builder = new TextExtractionBuilder($this->registry); + // Validation is left to File, so that all builders reject the same inputs identically. $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Cannot create a document from an empty string.'); + $this->expectExceptionMessage('Invalid file provided.'); $builder->withDocument(' '); } + /** + * Tests withDocument rejects file types text extraction cannot consume. + * + * @return void + */ + public function testWithDocumentRejectsUnsupportedMimeType(): void + { + $builder = new TextExtractionBuilder($this->registry); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Text extraction supports image and document files, got "audio/mpeg".'); + + $builder->withDocument('https://example.com/podcast.mp3'); + } + + /** + * Tests withDocument accepts image and document files. + * + * @return void + */ + public function testWithDocumentAcceptsImagesAndDocuments(): void + { + $builder = new TextExtractionBuilder($this->registry); + + $builder->withDocument('https://example.com/scan.png'); + $this->assertSame('image/png', $this->getDocument($builder)->getMimeType()); + + $builder->withDocument('https://example.com/report.pdf'); + $this->assertSame('application/pdf', $this->getDocument($builder)->getMimeType()); + } + /** * Tests withDocument rejects an unsupported input type. * @@ -379,4 +414,59 @@ public function testCloneWithoutDocument(): void $this->assertNull($this->getDocument($cloned)); $this->assertNotSame($this->getModelConfig($original), $this->getModelConfig($cloned)); } + + /** + * Tests the builder dispatches lifecycle events around extraction. + * + * @return void + */ + public function testExtractTextResultDispatchesLifecycleEvents(): void + { + $result = $this->createTestTextExtractionResult(['# Page one']); + $model = $this->createMockTextExtractionModel($result); + + $events = []; + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher + ->expects($this->exactly(2)) + ->method('dispatch') + ->willReturnCallback(static function (object $event) use (&$events): object { + $events[] = $event; + return $event; + }); + + $builder = new TextExtractionBuilder( + $this->registry, + 'https://example.com/document.pdf', + $dispatcher + ); + $builder->usingModel($model); + $builder->extractTextResult(); + + $this->assertInstanceOf(BeforeExtractTextEvent::class, $events[0]); + $this->assertInstanceOf(AfterExtractTextEvent::class, $events[1]); + + $this->assertSame('application/pdf', $events[0]->getDocument()->getMimeType()); + $this->assertSame($model, $events[0]->getModel()); + $this->assertTrue($events[0]->getCapability()->isTextExtraction()); + + $this->assertSame($result, $events[1]->getResult()); + $this->assertTrue($events[1]->getCapability()->isTextExtraction()); + } + + /** + * Tests the builder works without an event dispatcher. + * + * @return void + */ + public function testExtractTextResultWorksWithoutEventDispatcher(): void + { + $result = $this->createTestTextExtractionResult(['# Page one']); + $model = $this->createMockTextExtractionModel($result); + + $builder = new TextExtractionBuilder($this->registry, 'https://example.com/document.pdf'); + $builder->usingModel($model); + + $this->assertSame($result, $builder->extractTextResult()); + } } diff --git a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php index 4b1bf59d..1616dba0 100644 --- a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php +++ b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; @@ -905,4 +906,81 @@ public function testFromExtractionDataWithModelConfigOptions(): void $this->assertTrue($hasPagesOption, 'Custom pages option should be present'); } + + /** + * Tests fromExtractionData maps a text file to the document modality. + * + * @return void + */ + public function testFromExtractionDataWithTextFile(): void + { + $document = new File('https://example.com/notes.txt', 'text/plain'); + + $requirements = ModelRequirements::fromExtractionData($document, new ModelConfig()); + + $inputModalities = null; + foreach ($requirements->getRequiredOptions() as $option) { + if ($option->getName()->isInputModalities()) { + $inputModalities = $option->getValue(); + } + } + + $this->assertEquals([ModalityEnum::document()], $inputModalities); + } + + /** + * Tests fromExtractionData rejects file types that carry no readable text. + * + * Mapping these to the document modality would either surface as an unhelpful "no model + * found" error or select a model that then fails provider-side. + * + * @dataProvider dataUnsupportedExtractionFiles + * + * @param string $url The file URL. + * @param string $mimeType The expected MIME type named in the error message. + * @return void + */ + public function testFromExtractionDataRejectsUnsupportedFileTypes(string $url, string $mimeType): void + { + $file = new File($url); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf('Text extraction supports image and document files, got "%s".', $mimeType) + ); + + ModelRequirements::fromExtractionData($file, new ModelConfig()); + } + + /** + * Provides unsupported extraction input files. + * + * @return array + */ + public function dataUnsupportedExtractionFiles(): array + { + return [ + 'audio' => ['https://example.com/podcast.mp3', 'audio/mpeg'], + 'video' => ['https://example.com/clip.mp4', 'video/mp4'], + ]; + } + + /** + * Tests extractionInputModality returns the modality a model must support. + * + * @return void + */ + public function testExtractionInputModality(): void + { + $this->assertTrue( + ModelRequirements::extractionInputModality( + new File('https://example.com/report.pdf') + )->isDocument() + ); + $this->assertTrue( + ModelRequirements::extractionInputModality( + new File('https://example.com/scan.png') + )->isImage() + ); + } } diff --git a/tests/unit/Results/DTO/TextExtractionResultTest.php b/tests/unit/Results/DTO/TextExtractionResultTest.php index 6adb4fce..b8d58d8e 100644 --- a/tests/unit/Results/DTO/TextExtractionResultTest.php +++ b/tests/unit/Results/DTO/TextExtractionResultTest.php @@ -95,7 +95,6 @@ public function testToMarkdownJoinsPagesInOrder(): void $result = $this->createTextExtractionResult(); $this->assertSame("# Heading\n\nFirst page.\n\nSecond page.", $result->toMarkdown()); - $this->assertSame($result->toMarkdown(), $result->toText()); } public function testToArrayFromArrayRoundtrip(): void @@ -178,4 +177,105 @@ public function testJsonSchemaListsRequiredKeys(): void $this->assertContains(TextExtractionResult::KEY_ID, $schema['required']); $this->assertContains(TextExtractionResult::KEY_PAGES, $schema['required']); } + + public function testPageCountDefaultsToNumberOfPages(): void + { + $result = $this->createTextExtractionResult(); + + $this->assertCount(2, $result->getPages()); + $this->assertSame(2, $result->getPageCount()); + } + + public function testPageCountUsesProviderReportedValue(): void + { + // A page range was requested, but the provider billed for the whole document. + $result = new TextExtractionResult( + 'extraction-result-id', + [new ExtractedPage(3, 'Third page.')], + new TokenUsage(0, 0, 0), + new ProviderMetadata('mock', 'Mock Provider', ProviderTypeEnum::cloud()), + new ModelMetadata('mock-ocr-model', 'Mock OCR Model', [CapabilityEnum::textExtraction()], []), + [], + 12 + ); + + $this->assertCount(1, $result->getPages()); + $this->assertSame(12, $result->getPageCount()); + } + + public function testPageCountRoundtripsThroughArray(): void + { + $result = new TextExtractionResult( + 'extraction-result-id', + [new ExtractedPage(3, 'Third page.')], + new TokenUsage(0, 0, 0), + new ProviderMetadata('mock', 'Mock Provider', ProviderTypeEnum::cloud()), + new ModelMetadata('mock-ocr-model', 'Mock OCR Model', [CapabilityEnum::textExtraction()], []), + [], + 12 + ); + + $roundtripped = TextExtractionResult::fromArray($result->toArray()); + + $this->assertSame(12, $roundtripped->getPageCount()); + } + + public function testConstructorRejectsNonPositivePageCount(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Page count must be 1 or greater.'); + + new TextExtractionResult( + 'extraction-result-id', + [new ExtractedPage(1, 'First page.')], + new TokenUsage(0, 0, 0), + new ProviderMetadata('mock', 'Mock Provider', ProviderTypeEnum::cloud()), + new ModelMetadata('mock-ocr-model', 'Mock OCR Model', [CapabilityEnum::textExtraction()], []), + [], + 0 + ); + } + + public function testExtractedPageRejectsNonExtractedImageEntries(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All images must be ExtractedImage instances.'); + + /** @phpstan-ignore-next-line Intentionally invalid input. */ + new ExtractedPage(1, 'First page.', ['not-an-image']); + } + + /** + * Providers that decode JSON can surface integral values as floats, which strict_types would + * reject outright without the casts in fromArray(). + */ + public function testFromArrayAcceptsFloatIntegersForPageNumbersAndDimensions(): void + { + $page = ExtractedPage::fromArray([ + 'pageNumber' => 1.0, + 'markdown' => 'First page.', + 'dimensions' => [ + 'width' => 1700.0, + 'height' => 2200.0, + 'dpi' => 200.0, + ], + ]); + + $this->assertSame(1, $page->getPageNumber()); + $this->assertNotNull($page->getDimensions()); + $this->assertSame(1700, $page->getDimensions()->getWidth()); + $this->assertSame(2200, $page->getDimensions()->getHeight()); + $this->assertSame(200, $page->getDimensions()->getDpi()); + } + + public function testFromArrayStillRejectsOutOfRangePageNumbers(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Page number must be 1 or greater.'); + + ExtractedPage::fromArray([ + 'pageNumber' => 0.0, + 'markdown' => 'First page.', + ]); + } }