From 4ca77b088731c191439727d487f4d02738928f80 Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Mon, 24 Aug 2026 10:38:26 +0530 Subject: [PATCH 1/5] feat: add Citation DTO and support for citations in MessagePart --- src/Messages/DTO/Citation.php | 130 ++++++++++++++++++ src/Messages/DTO/MessagePart.php | 82 ++++++++++- tests/unit/Messages/DTO/CitationTest.php | 144 ++++++++++++++++++++ tests/unit/Messages/DTO/MessagePartTest.php | 114 ++++++++++++++++ 4 files changed, 463 insertions(+), 7 deletions(-) create mode 100644 src/Messages/DTO/Citation.php create mode 100644 tests/unit/Messages/DTO/CitationTest.php diff --git a/src/Messages/DTO/Citation.php b/src/Messages/DTO/Citation.php new file mode 100644 index 00000000..f3804035 --- /dev/null +++ b/src/Messages/DTO/Citation.php @@ -0,0 +1,130 @@ + + */ +class Citation extends AbstractDataTransferObject +{ + public const KEY_URI = 'uri'; + public const KEY_TITLE = 'title'; + + /** + * @var string The source URI or identifier. + */ + private string $uri; + + /** + * @var string|null An optional title for the source. + */ + private ?string $title; + + /** + * Constructor. + * + * @since 1.4.0 + * + * @param string $uri The source URI or identifier. + * @param string|null $title An optional title for the source. + */ + public function __construct(string $uri, ?string $title = null) + { + $this->uri = $uri; + $this->title = $title; + } + + /** + * Gets the source URI or identifier. + * + * @since 1.4.0 + * + * @return string + */ + public function getUri(): string + { + return $this->uri; + } + + /** + * Gets the title of the source. + * + * @since 1.4.0 + * + * @return string|null + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * {@inheritDoc} + * + * @since 1.4.0 + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + self::KEY_URI => [ + 'type' => 'string', + 'description' => 'The source URI or identifier.', + ], + self::KEY_TITLE => [ + 'type' => ['string', 'null'], + 'description' => 'An optional title for the source.', + ], + ], + 'required' => [self::KEY_URI], + 'additionalProperties' => false, + ]; + } + + /** + * {@inheritDoc} + * + * @since 1.4.0 + * + * @return CitationArrayShape + */ + public function toArray(): array + { + $data = [ + self::KEY_URI => $this->uri, + ]; + + if ($this->title !== null) { + $data[self::KEY_TITLE] = $this->title; + } + + return $data; + } + + /** + * {@inheritDoc} + * + * @since 1.4.0 + */ + public static function fromArray(array $array): self + { + return new self( + $array[self::KEY_URI], + $array[self::KEY_TITLE] ?? null + ); + } +} diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index 228471be..8fe6e2ef 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -24,6 +24,7 @@ * @phpstan-import-type FileArrayShape from File * @phpstan-import-type FunctionCallArrayShape from FunctionCall * @phpstan-import-type FunctionResponseArrayShape from FunctionResponse + * @phpstan-import-type CitationArrayShape from Citation * * @phpstan-type MessagePartArrayShape array{ * channel: string, @@ -32,7 +33,8 @@ * text?: string, * file?: FileArrayShape, * functionCall?: FunctionCallArrayShape, - * functionResponse?: FunctionResponseArrayShape + * functionResponse?: FunctionResponseArrayShape, + * citations?: array|null * } * * @extends AbstractDataTransferObject @@ -46,6 +48,7 @@ class MessagePart extends AbstractDataTransferObject public const KEY_FILE = 'file'; public const KEY_FUNCTION_CALL = 'functionCall'; public const KEY_FUNCTION_RESPONSE = 'functionResponse'; + public const KEY_CITATIONS = 'citations'; /** * @var MessagePartChannelEnum The channel this message part belongs to. @@ -82,6 +85,11 @@ class MessagePart extends AbstractDataTransferObject */ private ?FunctionResponse $functionResponse = null; + /** + * @var Citation[]|null Optional citations or source attributions. + */ + private ?array $citations = null; + /** * Constructor that accepts various content types and infers the message part type. * @@ -90,12 +98,18 @@ class MessagePart extends AbstractDataTransferObject * @param mixed $content The content of this message part. * @param MessagePartChannelEnum|null $channel The channel this part belongs to. Defaults to CONTENT. * @param string|null $thoughtSignature Optional thought signature for extended thinking. + * @param Citation[]|null $citations Optional citations for the content. * @throws InvalidArgumentException If an unsupported content type is provided. */ - public function __construct($content, ?MessagePartChannelEnum $channel = null, ?string $thoughtSignature = null) - { + public function __construct( + $content, + ?MessagePartChannelEnum $channel = null, + ?string $thoughtSignature = null, + ?array $citations = null + ) { $this->channel = $channel ?? MessagePartChannelEnum::content(); $this->thoughtSignature = $thoughtSignature; + $this->citations = $citations; if (is_string($content)) { $this->type = MessagePartTypeEnum::text(); @@ -205,6 +219,18 @@ public function getFunctionResponse(): ?FunctionResponse return $this->functionResponse; } + /** + * Gets the citations. + * + * @since 1.4.0 + * + * @return Citation[]|null The citations or null if not set. + */ + public function getCitations(): ?array + { + return $this->citations; + } + /** * {@inheritDoc} * @@ -223,6 +249,12 @@ public static function getJsonSchema(): array 'description' => 'Thought signature for extended thinking.', ]; + $citationsSchema = [ + 'type' => 'array', + 'items' => Citation::getJsonSchema(), + 'description' => 'Optional citations or source attributions.', + ]; + return [ 'oneOf' => [ [ @@ -238,6 +270,7 @@ public static function getJsonSchema(): array 'description' => 'Text content.', ], self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema, + self::KEY_CITATIONS => $citationsSchema, ], 'required' => [self::KEY_TYPE, self::KEY_TEXT], 'additionalProperties' => false, @@ -252,6 +285,7 @@ public static function getJsonSchema(): array ], self::KEY_FILE => File::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema, + self::KEY_CITATIONS => $citationsSchema, ], 'required' => [self::KEY_TYPE, self::KEY_FILE], 'additionalProperties' => false, @@ -266,6 +300,7 @@ public static function getJsonSchema(): array ], self::KEY_FUNCTION_CALL => FunctionCall::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema, + self::KEY_CITATIONS => $citationsSchema, ], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_CALL], 'additionalProperties' => false, @@ -280,6 +315,7 @@ public static function getJsonSchema(): array ], self::KEY_FUNCTION_RESPONSE => FunctionResponse::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema, + self::KEY_CITATIONS => $citationsSchema, ], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_RESPONSE], 'additionalProperties' => false, @@ -321,6 +357,13 @@ public function toArray(): array $data[self::KEY_THOUGHT_SIGNATURE] = $this->thoughtSignature; } + if ($this->citations !== null) { + $data[self::KEY_CITATIONS] = array_map( + static fn (Citation $citation) => $citation->toArray(), + $this->citations + ); + } + return $data; } @@ -339,18 +382,37 @@ public static function fromArray(array $array): self $thoughtSignature = $array[self::KEY_THOUGHT_SIGNATURE] ?? null; + $citations = null; + if (isset($array[self::KEY_CITATIONS])) { + $citations = array_map( + static fn (array $citationArray) => Citation::fromArray($citationArray), + $array[self::KEY_CITATIONS] + ); + } + // Check which properties are set to determine how to construct the MessagePart if (isset($array[self::KEY_TEXT])) { - return new self($array[self::KEY_TEXT], $channel, $thoughtSignature); + return new self($array[self::KEY_TEXT], $channel, $thoughtSignature, $citations); } elseif (isset($array[self::KEY_FILE])) { - return new self(File::fromArray($array[self::KEY_FILE]), $channel, $thoughtSignature); + return new self( + File::fromArray($array[self::KEY_FILE]), + $channel, + $thoughtSignature, + $citations + ); } elseif (isset($array[self::KEY_FUNCTION_CALL])) { - return new self(FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL]), $channel, $thoughtSignature); + return new self( + FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL]), + $channel, + $thoughtSignature, + $citations + ); } elseif (isset($array[self::KEY_FUNCTION_RESPONSE])) { return new self( FunctionResponse::fromArray($array[self::KEY_FUNCTION_RESPONSE]), $channel, - $thoughtSignature + $thoughtSignature, + $citations ); } else { throw new InvalidArgumentException( @@ -378,5 +440,11 @@ public function __clone() if ($this->functionResponse !== null) { $this->functionResponse = clone $this->functionResponse; } + if ($this->citations !== null) { + $this->citations = array_map( + static fn (Citation $citation) => clone $citation, + $this->citations + ); + } } } diff --git a/tests/unit/Messages/DTO/CitationTest.php b/tests/unit/Messages/DTO/CitationTest.php new file mode 100644 index 00000000..0b9a0901 --- /dev/null +++ b/tests/unit/Messages/DTO/CitationTest.php @@ -0,0 +1,144 @@ +assertEquals($uri, $citation->getUri()); + $this->assertNull($citation->getTitle()); + } + + /** + * Tests creating Citation with URI and title. + * + * @return void + */ + public function testCreateWithUriAndTitle(): void + { + $uri = 'https://example.com/doc'; + $title = 'Example Document'; + $citation = new Citation($uri, $title); + + $this->assertEquals($uri, $citation->getUri()); + $this->assertEquals($title, $citation->getTitle()); + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = Citation::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey(Citation::KEY_URI, $schema['properties']); + $this->assertArrayHasKey(Citation::KEY_TITLE, $schema['properties']); + $this->assertEquals(['uri'], $schema['required']); + $this->assertFalse($schema['additionalProperties']); + } + + /** + * Tests array transformation with URI only. + * + * @return void + */ + public function testToArrayWithUriOnly(): void + { + $citation = new Citation('https://example.com/doc'); + $json = $citation->toArray(); + + $this->assertIsArray($json); + $this->assertArrayHasKey(Citation::KEY_URI, $json); + $this->assertEquals('https://example.com/doc', $json[Citation::KEY_URI]); + $this->assertArrayNotHasKey(Citation::KEY_TITLE, $json); + } + + /** + * Tests array transformation with URI and title. + * + * @return void + */ + public function testToArrayWithUriAndTitle(): void + { + $citation = new Citation('https://example.com/doc', 'Doc Title'); + $json = $citation->toArray(); + + $this->assertIsArray($json); + $this->assertArrayHasKey(Citation::KEY_URI, $json); + $this->assertArrayHasKey(Citation::KEY_TITLE, $json); + $this->assertEquals('https://example.com/doc', $json[Citation::KEY_URI]); + $this->assertEquals('Doc Title', $json[Citation::KEY_TITLE]); + } + + /** + * Tests fromArray with URI only. + * + * @return void + */ + public function testFromArrayWithUriOnly(): void + { + $json = [ + Citation::KEY_URI => 'https://example.com/doc', + ]; + + $citation = Citation::fromArray($json); + + $this->assertEquals('https://example.com/doc', $citation->getUri()); + $this->assertNull($citation->getTitle()); + } + + /** + * Tests fromArray with URI and title. + * + * @return void + */ + public function testFromArrayWithUriAndTitle(): void + { + $json = [ + Citation::KEY_URI => 'https://example.com/doc', + Citation::KEY_TITLE => 'Doc Title', + ]; + + $citation = Citation::fromArray($json); + + $this->assertEquals('https://example.com/doc', $citation->getUri()); + $this->assertEquals('Doc Title', $citation->getTitle()); + } + + /** + * Tests round-trip array transformation. + * + * @return void + */ + public function testArrayRoundTrip(): void + { + $original = new Citation('https://example.com/doc', 'Doc Title'); + $array = $original->toArray(); + $restored = Citation::fromArray($array); + + $this->assertEquals($original->getUri(), $restored->getUri()); + $this->assertEquals($original->getTitle(), $restored->getTitle()); + } +} diff --git a/tests/unit/Messages/DTO/MessagePartTest.php b/tests/unit/Messages/DTO/MessagePartTest.php index de7a4ae0..fb0378d5 100644 --- a/tests/unit/Messages/DTO/MessagePartTest.php +++ b/tests/unit/Messages/DTO/MessagePartTest.php @@ -10,6 +10,7 @@ use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; +use WordPress\AiClient\Messages\DTO\Citation; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; @@ -37,6 +38,7 @@ public function testCreateWithTextContent(): void $this->assertNull($part->getFile()); $this->assertNull($part->getFunctionCall()); $this->assertNull($part->getFunctionResponse()); + $this->assertNull($part->getCitations()); } /** @@ -55,6 +57,7 @@ public function testCreateWithFileContent(): void $this->assertSame($file, $part->getFile()); $this->assertNull($part->getFunctionCall()); $this->assertNull($part->getFunctionResponse()); + $this->assertNull($part->getCitations()); } /** @@ -630,4 +633,115 @@ public function testJsonSchemaIncludesThoughtSignature(): void ); } } + + /** + * Tests creating MessagePart with citations. + * + * @return void + */ + public function testCreateWithCitations(): void + { + $citations = [ + new Citation('https://example.com/doc1', 'Doc 1'), + new Citation('https://example.com/doc2'), + ]; + + $part = new MessagePart( + 'Text with citations', + MessagePartChannelEnum::content(), + null, + $citations + ); + + $this->assertEquals($citations, $part->getCitations()); + $this->assertCount(2, $part->getCitations()); + } + + /** + * Tests toArray includes citations when set. + * + * @return void + */ + public function testToArrayIncludesCitations(): void + { + $citations = [new Citation('https://example.com/doc1', 'Doc 1')]; + $part = new MessagePart( + 'Text with citations', + MessagePartChannelEnum::content(), + null, + $citations + ); + + $array = $part->toArray(); + + $this->assertArrayHasKey(MessagePart::KEY_CITATIONS, $array); + $this->assertIsArray($array[MessagePart::KEY_CITATIONS]); + $this->assertCount(1, $array[MessagePart::KEY_CITATIONS]); + $this->assertEquals('https://example.com/doc1', $array[MessagePart::KEY_CITATIONS][0][Citation::KEY_URI]); + } + + /** + * Tests fromArray with citations. + * + * @return void + */ + public function testFromArrayWithCitations(): void + { + $array = [ + MessagePart::KEY_CHANNEL => MessagePartChannelEnum::content()->value, + MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, + MessagePart::KEY_TEXT => 'Text with citations', + MessagePart::KEY_CITATIONS => [ + [ + Citation::KEY_URI => 'https://example.com/doc1', + Citation::KEY_TITLE => 'Doc 1', + ] + ], + ]; + + $part = MessagePart::fromArray($array); + + $this->assertNotNull($part->getCitations()); + $this->assertCount(1, $part->getCitations()); + $this->assertEquals('https://example.com/doc1', $part->getCitations()[0]->getUri()); + $this->assertEquals('Doc 1', $part->getCitations()[0]->getTitle()); + } + + /** + * Tests round-trip array transformation with citations. + * + * @return void + */ + public function testArrayRoundTripWithCitations(): void + { + $citations = [new Citation('https://example.com/doc1', 'Doc 1')]; + $original = new MessagePart( + 'Text with citations', + MessagePartChannelEnum::content(), + null, + $citations + ); + + $array = $original->toArray(); + $restored = MessagePart::fromArray($array); + + $this->assertEquals($original->getText(), $restored->getText()); + $this->assertNotNull($restored->getCitations()); + $this->assertCount(1, $restored->getCitations()); + $this->assertEquals('https://example.com/doc1', $restored->getCitations()[0]->getUri()); + } + + /** + * Tests that cloning MessagePart with citations creates independent copies. + * + * @return void + */ + public function testCloneClonesCitations(): void + { + $citations = [new Citation('https://example.com/doc1')]; + $original = new MessagePart('text', null, null, $citations); + $cloned = clone $original; + + $this->assertNotSame($original->getCitations()[0], $cloned->getCitations()[0]); + } } From 6690dc07ac2c19f82b2783907796fa23d6ba0695 Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Sat, 29 Aug 2026 17:06:50 +0530 Subject: [PATCH 2/5] refactor: expand Citation DTO to support document indexes, text offsets, and quoted passages with validation --- src/Messages/DTO/Citation.php | 219 +++++++++-- tests/unit/Messages/DTO/CitationTest.php | 382 +++++++++++++++++--- tests/unit/Messages/DTO/MessagePartTest.php | 50 ++- 3 files changed, 562 insertions(+), 89 deletions(-) diff --git a/src/Messages/DTO/Citation.php b/src/Messages/DTO/Citation.php index f3804035..afa97ddf 100644 --- a/src/Messages/DTO/Citation.php +++ b/src/Messages/DTO/Citation.php @@ -5,92 +5,227 @@ namespace WordPress\AiClient\Messages\DTO; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; /** - * Represents a citation or source attribution. + * Represents a citation or source attribution for generated text. * - * @since 1.4.0 + * Citations anchor a span of generated text to its source, which can be + * either a remote URL or an index into the request's documents array. + * Offset indices are relative to the owning MessagePart's text property. + * + * @since n.e.x.t * * @phpstan-type CitationArrayShape array{ - * uri: string, - * title?: string|null + * url?: string|null, + * documentIndex?: int|null, + * title?: string|null, + * startIndex?: int|null, + * endIndex?: int|null, + * quotedText?: string|null * } * * @extends AbstractDataTransferObject */ class Citation extends AbstractDataTransferObject { - public const KEY_URI = 'uri'; + public const KEY_URL = 'url'; + public const KEY_DOCUMENT_INDEX = 'documentIndex'; public const KEY_TITLE = 'title'; + public const KEY_START_INDEX = 'startIndex'; + public const KEY_END_INDEX = 'endIndex'; + public const KEY_QUOTED_TEXT = 'quotedText'; /** - * @var string The source URI or identifier. + * @var string|null The remote source URL. */ - private string $uri; + private ?string $url; + + /** + * @var int|null The index into the request's documents array. + */ + private ?int $documentIndex; /** * @var string|null An optional title for the source. */ private ?string $title; + /** + * @var int|null The start byte offset into the owning MessagePart's text. + */ + private ?int $startIndex; + + /** + * @var int|null The end byte offset into the owning MessagePart's text. + */ + private ?int $endIndex; + + /** + * @var string|null The quoted source passage (e.g. Anthropic's cited_text). + */ + private ?string $quotedText; + /** * Constructor. * - * @since 1.4.0 + * @since n.e.x.t * - * @param string $uri The source URI or identifier. + * @param string|null $url The remote source URL. + * @param int|null $documentIndex The index into the request's documents array. * @param string|null $title An optional title for the source. + * @param int|null $startIndex The start byte offset into the owning part's text. + * @param int|null $endIndex The end byte offset into the owning part's text. + * @param string|null $quotedText The quoted source passage. + * @throws InvalidArgumentException If offsets are negative or misordered. */ - public function __construct(string $uri, ?string $title = null) - { - $this->uri = $uri; + public function __construct( + ?string $url = null, + ?int $documentIndex = null, + ?string $title = null, + ?int $startIndex = null, + ?int $endIndex = null, + ?string $quotedText = null + ) { + if ($startIndex !== null && $startIndex < 0) { + throw new InvalidArgumentException( + sprintf('Citation startIndex must be non-negative, got %d.', $startIndex) + ); + } + + if ($endIndex !== null && $endIndex < 0) { + throw new InvalidArgumentException( + sprintf('Citation endIndex must be non-negative, got %d.', $endIndex) + ); + } + + if ($startIndex !== null && $endIndex !== null && $startIndex > $endIndex) { + throw new InvalidArgumentException( + sprintf( + 'Citation startIndex (%d) must be less than or equal to endIndex (%d).', + $startIndex, + $endIndex + ) + ); + } + + $this->url = $url; + $this->documentIndex = $documentIndex; $this->title = $title; + $this->startIndex = $startIndex; + $this->endIndex = $endIndex; + $this->quotedText = $quotedText; + } + + /** + * Gets the remote source URL. + * + * @since n.e.x.t + * + * @return string|null The URL or null if the source is a document reference. + */ + public function getUrl(): ?string + { + return $this->url; } /** - * Gets the source URI or identifier. + * Gets the document index. * - * @since 1.4.0 + * @since n.e.x.t * - * @return string + * @return int|null The document index or null if the source is a URL. */ - public function getUri(): string + public function getDocumentIndex(): ?int { - return $this->uri; + return $this->documentIndex; } /** * Gets the title of the source. * - * @since 1.4.0 + * @since n.e.x.t * - * @return string|null + * @return string|null The title or null if not available. */ public function getTitle(): ?string { return $this->title; } + /** + * Gets the start byte offset into the owning MessagePart's text. + * + * @since n.e.x.t + * + * @return int|null The start offset or null if span is not available. + */ + public function getStartIndex(): ?int + { + return $this->startIndex; + } + + /** + * Gets the end byte offset into the owning MessagePart's text. + * + * @since n.e.x.t + * + * @return int|null The end offset or null if span is not available. + */ + public function getEndIndex(): ?int + { + return $this->endIndex; + } + + /** + * Gets the quoted source passage. + * + * @since n.e.x.t + * + * @return string|null The quoted text or null if not available. + */ + public function getQuotedText(): ?string + { + return $this->quotedText; + } + /** * {@inheritDoc} * - * @since 1.4.0 + * @since n.e.x.t */ public static function getJsonSchema(): array { return [ 'type' => 'object', 'properties' => [ - self::KEY_URI => [ - 'type' => 'string', - 'description' => 'The source URI or identifier.', + self::KEY_URL => [ + 'type' => ['string', 'null'], + 'description' => 'The remote source URL.', + ], + self::KEY_DOCUMENT_INDEX => [ + 'type' => ['integer', 'null'], + 'description' => 'The index into the request\'s documents array.', ], self::KEY_TITLE => [ 'type' => ['string', 'null'], 'description' => 'An optional title for the source.', ], + self::KEY_START_INDEX => [ + 'type' => ['integer', 'null'], + 'minimum' => 0, + 'description' => 'The start byte offset into the owning MessagePart\'s text.', + ], + self::KEY_END_INDEX => [ + 'type' => ['integer', 'null'], + 'minimum' => 0, + 'description' => 'The end byte offset into the owning MessagePart\'s text.', + ], + self::KEY_QUOTED_TEXT => [ + 'type' => ['string', 'null'], + 'description' => 'The quoted source passage.', + ], ], - 'required' => [self::KEY_URI], 'additionalProperties' => false, ]; } @@ -98,33 +233,55 @@ public static function getJsonSchema(): array /** * {@inheritDoc} * - * @since 1.4.0 + * @since n.e.x.t * * @return CitationArrayShape */ public function toArray(): array { - $data = [ - self::KEY_URI => $this->uri, - ]; + $data = []; + + if ($this->url !== null) { + $data[self::KEY_URL] = $this->url; + } + + if ($this->documentIndex !== null) { + $data[self::KEY_DOCUMENT_INDEX] = $this->documentIndex; + } if ($this->title !== null) { $data[self::KEY_TITLE] = $this->title; } + if ($this->startIndex !== null) { + $data[self::KEY_START_INDEX] = $this->startIndex; + } + + if ($this->endIndex !== null) { + $data[self::KEY_END_INDEX] = $this->endIndex; + } + + if ($this->quotedText !== null) { + $data[self::KEY_QUOTED_TEXT] = $this->quotedText; + } + return $data; } /** * {@inheritDoc} * - * @since 1.4.0 + * @since n.e.x.t */ public static function fromArray(array $array): self { return new self( - $array[self::KEY_URI], - $array[self::KEY_TITLE] ?? null + $array[self::KEY_URL] ?? null, + $array[self::KEY_DOCUMENT_INDEX] ?? null, + $array[self::KEY_TITLE] ?? null, + $array[self::KEY_START_INDEX] ?? null, + $array[self::KEY_END_INDEX] ?? null, + $array[self::KEY_QUOTED_TEXT] ?? null ); } } diff --git a/tests/unit/Messages/DTO/CitationTest.php b/tests/unit/Messages/DTO/CitationTest.php index 0b9a0901..0c23d7fd 100644 --- a/tests/unit/Messages/DTO/CitationTest.php +++ b/tests/unit/Messages/DTO/CitationTest.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Tests\unit\Messages\DTO; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Messages\DTO\Citation; /** @@ -13,36 +14,172 @@ class CitationTest extends TestCase { /** - * Tests creating Citation with URI only. + * Tests creating Citation with URL only. * * @return void */ - public function testCreateWithUriOnly(): void + public function testCreateWithUrlOnly(): void { - $uri = 'https://example.com/doc'; - $citation = new Citation($uri); + $citation = new Citation('https://example.com/doc'); + + $this->assertEquals('https://example.com/doc', $citation->getUrl()); + $this->assertNull($citation->getDocumentIndex()); + $this->assertNull($citation->getTitle()); + $this->assertNull($citation->getStartIndex()); + $this->assertNull($citation->getEndIndex()); + $this->assertNull($citation->getQuotedText()); + } + + /** + * Tests creating Citation with document index only. + * + * @return void + */ + public function testCreateWithDocumentIndexOnly(): void + { + $citation = new Citation(null, 2); - $this->assertEquals($uri, $citation->getUri()); + $this->assertNull($citation->getUrl()); + $this->assertEquals(2, $citation->getDocumentIndex()); $this->assertNull($citation->getTitle()); } /** - * Tests creating Citation with URI and title. + * Tests creating Citation with all fields populated. + * + * @return void + */ + public function testCreateWithAllFields(): void + { + $citation = new Citation( + 'https://example.com/doc', + null, + 'Example Document', + 10, + 50, + 'The quoted passage from the source.' + ); + + $this->assertEquals('https://example.com/doc', $citation->getUrl()); + $this->assertNull($citation->getDocumentIndex()); + $this->assertEquals('Example Document', $citation->getTitle()); + $this->assertEquals(10, $citation->getStartIndex()); + $this->assertEquals(50, $citation->getEndIndex()); + $this->assertEquals('The quoted passage from the source.', $citation->getQuotedText()); + } + + /** + * Tests creating Citation with document index and span. + * + * @return void + */ + public function testCreateWithDocumentIndexAndSpan(): void + { + $citation = new Citation(null, 0, 'Report', 5, 20); + + $this->assertNull($citation->getUrl()); + $this->assertEquals(0, $citation->getDocumentIndex()); + $this->assertEquals('Report', $citation->getTitle()); + $this->assertEquals(5, $citation->getStartIndex()); + $this->assertEquals(20, $citation->getEndIndex()); + $this->assertNull($citation->getQuotedText()); + } + + /** + * Tests creating Citation with equal start and end index. + * + * @return void + */ + public function testCreateWithEqualStartAndEndIndex(): void + { + $citation = new Citation('https://example.com', null, null, 10, 10); + + $this->assertEquals(10, $citation->getStartIndex()); + $this->assertEquals(10, $citation->getEndIndex()); + } + + /** + * Tests creating Citation with zero start and end index. + * + * @return void + */ + public function testCreateWithZeroIndices(): void + { + $citation = new Citation('https://example.com', null, null, 0, 0); + + $this->assertEquals(0, $citation->getStartIndex()); + $this->assertEquals(0, $citation->getEndIndex()); + } + + /** + * Tests that negative startIndex throws exception. + * + * @return void + */ + public function testNegativeStartIndexThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Citation startIndex must be non-negative, got -1.'); + + new Citation('https://example.com', null, null, -1, 10); + } + + /** + * Tests that negative endIndex throws exception. * * @return void */ - public function testCreateWithUriAndTitle(): void + public function testNegativeEndIndexThrowsException(): void { - $uri = 'https://example.com/doc'; - $title = 'Example Document'; - $citation = new Citation($uri, $title); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Citation endIndex must be non-negative, got -5.'); - $this->assertEquals($uri, $citation->getUri()); - $this->assertEquals($title, $citation->getTitle()); + new Citation('https://example.com', null, null, 0, -5); } /** - * Tests JSON schema. + * Tests that startIndex greater than endIndex throws exception. + * + * @return void + */ + public function testStartIndexGreaterThanEndIndexThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Citation startIndex (20) must be less than or equal to endIndex (10).' + ); + + new Citation('https://example.com', null, null, 20, 10); + } + + /** + * Tests that startIndex without endIndex does not throw. + * + * @return void + */ + public function testStartIndexWithoutEndIndex(): void + { + $citation = new Citation('https://example.com', null, null, 5, null); + + $this->assertEquals(5, $citation->getStartIndex()); + $this->assertNull($citation->getEndIndex()); + } + + /** + * Tests that endIndex without startIndex does not throw. + * + * @return void + */ + public function testEndIndexWithoutStartIndex(): void + { + $citation = new Citation('https://example.com', null, null, null, 20); + + $this->assertNull($citation->getStartIndex()); + $this->assertEquals(20, $citation->getEndIndex()); + } + + /** + * Tests JSON schema has all expected properties. * * @return void */ @@ -53,92 +190,243 @@ public function testJsonSchema(): void $this->assertIsArray($schema); $this->assertEquals('object', $schema['type']); $this->assertArrayHasKey('properties', $schema); - $this->assertArrayHasKey(Citation::KEY_URI, $schema['properties']); + $this->assertArrayHasKey(Citation::KEY_URL, $schema['properties']); + $this->assertArrayHasKey(Citation::KEY_DOCUMENT_INDEX, $schema['properties']); $this->assertArrayHasKey(Citation::KEY_TITLE, $schema['properties']); - $this->assertEquals(['uri'], $schema['required']); + $this->assertArrayHasKey(Citation::KEY_START_INDEX, $schema['properties']); + $this->assertArrayHasKey(Citation::KEY_END_INDEX, $schema['properties']); + $this->assertArrayHasKey(Citation::KEY_QUOTED_TEXT, $schema['properties']); $this->assertFalse($schema['additionalProperties']); + // No required fields — all are nullable. + $this->assertArrayNotHasKey('required', $schema); + } + + /** + * Tests JSON schema index properties include minimum constraint. + * + * @return void + */ + public function testJsonSchemaIndexMinimumConstraint(): void + { + $schema = Citation::getJsonSchema(); + + $this->assertEquals(0, $schema['properties'][Citation::KEY_START_INDEX]['minimum']); + $this->assertEquals(0, $schema['properties'][Citation::KEY_END_INDEX]['minimum']); } /** - * Tests array transformation with URI only. + * Tests toArray with URL only. * * @return void */ - public function testToArrayWithUriOnly(): void + public function testToArrayWithUrlOnly(): void { $citation = new Citation('https://example.com/doc'); - $json = $citation->toArray(); + $array = $citation->toArray(); - $this->assertIsArray($json); - $this->assertArrayHasKey(Citation::KEY_URI, $json); - $this->assertEquals('https://example.com/doc', $json[Citation::KEY_URI]); - $this->assertArrayNotHasKey(Citation::KEY_TITLE, $json); + $this->assertIsArray($array); + $this->assertArrayHasKey(Citation::KEY_URL, $array); + $this->assertEquals('https://example.com/doc', $array[Citation::KEY_URL]); + $this->assertArrayNotHasKey(Citation::KEY_DOCUMENT_INDEX, $array); + $this->assertArrayNotHasKey(Citation::KEY_TITLE, $array); + $this->assertArrayNotHasKey(Citation::KEY_START_INDEX, $array); + $this->assertArrayNotHasKey(Citation::KEY_END_INDEX, $array); + $this->assertArrayNotHasKey(Citation::KEY_QUOTED_TEXT, $array); } /** - * Tests array transformation with URI and title. + * Tests toArray with document index only. * * @return void */ - public function testToArrayWithUriAndTitle(): void + public function testToArrayWithDocumentIndexOnly(): void { - $citation = new Citation('https://example.com/doc', 'Doc Title'); - $json = $citation->toArray(); + $citation = new Citation(null, 3); + $array = $citation->toArray(); - $this->assertIsArray($json); - $this->assertArrayHasKey(Citation::KEY_URI, $json); - $this->assertArrayHasKey(Citation::KEY_TITLE, $json); - $this->assertEquals('https://example.com/doc', $json[Citation::KEY_URI]); - $this->assertEquals('Doc Title', $json[Citation::KEY_TITLE]); + $this->assertArrayNotHasKey(Citation::KEY_URL, $array); + $this->assertArrayHasKey(Citation::KEY_DOCUMENT_INDEX, $array); + $this->assertEquals(3, $array[Citation::KEY_DOCUMENT_INDEX]); } /** - * Tests fromArray with URI only. + * Tests toArray with all fields populated. * * @return void */ - public function testFromArrayWithUriOnly(): void + public function testToArrayWithAllFields(): void { - $json = [ - Citation::KEY_URI => 'https://example.com/doc', + $citation = new Citation( + 'https://example.com/doc', + 1, + 'Doc Title', + 10, + 50, + 'Quoted passage' + ); + $array = $citation->toArray(); + + $this->assertEquals('https://example.com/doc', $array[Citation::KEY_URL]); + $this->assertEquals(1, $array[Citation::KEY_DOCUMENT_INDEX]); + $this->assertEquals('Doc Title', $array[Citation::KEY_TITLE]); + $this->assertEquals(10, $array[Citation::KEY_START_INDEX]); + $this->assertEquals(50, $array[Citation::KEY_END_INDEX]); + $this->assertEquals('Quoted passage', $array[Citation::KEY_QUOTED_TEXT]); + } + + /** + * Tests toArray omits null fields. + * + * @return void + */ + public function testToArrayOmitsNullFields(): void + { + $citation = new Citation('https://example.com', null, 'Title'); + $array = $citation->toArray(); + + $this->assertArrayHasKey(Citation::KEY_URL, $array); + $this->assertArrayHasKey(Citation::KEY_TITLE, $array); + $this->assertArrayNotHasKey(Citation::KEY_DOCUMENT_INDEX, $array); + $this->assertArrayNotHasKey(Citation::KEY_START_INDEX, $array); + $this->assertArrayNotHasKey(Citation::KEY_END_INDEX, $array); + $this->assertArrayNotHasKey(Citation::KEY_QUOTED_TEXT, $array); + } + + /** + * Tests fromArray with URL only. + * + * @return void + */ + public function testFromArrayWithUrlOnly(): void + { + $array = [ + Citation::KEY_URL => 'https://example.com/doc', ]; - $citation = Citation::fromArray($json); + $citation = Citation::fromArray($array); - $this->assertEquals('https://example.com/doc', $citation->getUri()); + $this->assertEquals('https://example.com/doc', $citation->getUrl()); + $this->assertNull($citation->getDocumentIndex()); $this->assertNull($citation->getTitle()); + $this->assertNull($citation->getStartIndex()); + $this->assertNull($citation->getEndIndex()); + $this->assertNull($citation->getQuotedText()); } /** - * Tests fromArray with URI and title. + * Tests fromArray with document index only. * * @return void */ - public function testFromArrayWithUriAndTitle(): void + public function testFromArrayWithDocumentIndexOnly(): void { - $json = [ - Citation::KEY_URI => 'https://example.com/doc', + $array = [ + Citation::KEY_DOCUMENT_INDEX => 2, + ]; + + $citation = Citation::fromArray($array); + + $this->assertNull($citation->getUrl()); + $this->assertEquals(2, $citation->getDocumentIndex()); + } + + /** + * Tests fromArray with all fields. + * + * @return void + */ + public function testFromArrayWithAllFields(): void + { + $array = [ + Citation::KEY_URL => 'https://example.com/doc', + Citation::KEY_DOCUMENT_INDEX => 1, Citation::KEY_TITLE => 'Doc Title', + Citation::KEY_START_INDEX => 10, + Citation::KEY_END_INDEX => 50, + Citation::KEY_QUOTED_TEXT => 'Quoted passage', ]; - $citation = Citation::fromArray($json); + $citation = Citation::fromArray($array); - $this->assertEquals('https://example.com/doc', $citation->getUri()); + $this->assertEquals('https://example.com/doc', $citation->getUrl()); + $this->assertEquals(1, $citation->getDocumentIndex()); $this->assertEquals('Doc Title', $citation->getTitle()); + $this->assertEquals(10, $citation->getStartIndex()); + $this->assertEquals(50, $citation->getEndIndex()); + $this->assertEquals('Quoted passage', $citation->getQuotedText()); + } + + /** + * Tests fromArray with empty array creates all-null citation. + * + * @return void + */ + public function testFromArrayWithEmptyArray(): void + { + $citation = Citation::fromArray([]); + + $this->assertNull($citation->getUrl()); + $this->assertNull($citation->getDocumentIndex()); + $this->assertNull($citation->getTitle()); + $this->assertNull($citation->getStartIndex()); + $this->assertNull($citation->getEndIndex()); + $this->assertNull($citation->getQuotedText()); + } + + /** + * Tests round-trip array transformation with URL and span. + * + * @return void + */ + public function testArrayRoundTripWithUrlAndSpan(): void + { + $original = new Citation('https://example.com/doc', null, 'Doc Title', 5, 25, 'some text'); + $array = $original->toArray(); + $restored = Citation::fromArray($array); + + $this->assertEquals($original->getUrl(), $restored->getUrl()); + $this->assertEquals($original->getDocumentIndex(), $restored->getDocumentIndex()); + $this->assertEquals($original->getTitle(), $restored->getTitle()); + $this->assertEquals($original->getStartIndex(), $restored->getStartIndex()); + $this->assertEquals($original->getEndIndex(), $restored->getEndIndex()); + $this->assertEquals($original->getQuotedText(), $restored->getQuotedText()); } /** - * Tests round-trip array transformation. + * Tests round-trip array transformation with document index. * * @return void */ - public function testArrayRoundTrip(): void + public function testArrayRoundTripWithDocumentIndex(): void { - $original = new Citation('https://example.com/doc', 'Doc Title'); + $original = new Citation(null, 4, 'Internal Doc', 0, 100); $array = $original->toArray(); $restored = Citation::fromArray($array); - $this->assertEquals($original->getUri(), $restored->getUri()); + $this->assertEquals($original->getUrl(), $restored->getUrl()); + $this->assertEquals($original->getDocumentIndex(), $restored->getDocumentIndex()); $this->assertEquals($original->getTitle(), $restored->getTitle()); + $this->assertEquals($original->getStartIndex(), $restored->getStartIndex()); + $this->assertEquals($original->getEndIndex(), $restored->getEndIndex()); + $this->assertNull($restored->getQuotedText()); + } + + /** + * Tests round-trip array transformation with minimal citation. + * + * @return void + */ + public function testArrayRoundTripMinimal(): void + { + $original = new Citation('https://example.com'); + $array = $original->toArray(); + $restored = Citation::fromArray($array); + + $this->assertEquals($original->getUrl(), $restored->getUrl()); + $this->assertNull($restored->getDocumentIndex()); + $this->assertNull($restored->getTitle()); + $this->assertNull($restored->getStartIndex()); + $this->assertNull($restored->getEndIndex()); + $this->assertNull($restored->getQuotedText()); } } diff --git a/tests/unit/Messages/DTO/MessagePartTest.php b/tests/unit/Messages/DTO/MessagePartTest.php index fb0378d5..0e89b7c8 100644 --- a/tests/unit/Messages/DTO/MessagePartTest.php +++ b/tests/unit/Messages/DTO/MessagePartTest.php @@ -642,8 +642,8 @@ public function testJsonSchemaIncludesThoughtSignature(): void public function testCreateWithCitations(): void { $citations = [ - new Citation('https://example.com/doc1', 'Doc 1'), - new Citation('https://example.com/doc2'), + new Citation('https://example.com/doc1', null, 'Doc 1', 0, 20), + new Citation(null, 2, null, 21, 40, 'quoted text'), ]; $part = new MessagePart( @@ -664,7 +664,7 @@ public function testCreateWithCitations(): void */ public function testToArrayIncludesCitations(): void { - $citations = [new Citation('https://example.com/doc1', 'Doc 1')]; + $citations = [new Citation('https://example.com/doc1', null, 'Doc 1', 0, 15)]; $part = new MessagePart( 'Text with citations', MessagePartChannelEnum::content(), @@ -677,7 +677,10 @@ public function testToArrayIncludesCitations(): void $this->assertArrayHasKey(MessagePart::KEY_CITATIONS, $array); $this->assertIsArray($array[MessagePart::KEY_CITATIONS]); $this->assertCount(1, $array[MessagePart::KEY_CITATIONS]); - $this->assertEquals('https://example.com/doc1', $array[MessagePart::KEY_CITATIONS][0][Citation::KEY_URI]); + $this->assertEquals('https://example.com/doc1', $array[MessagePart::KEY_CITATIONS][0][Citation::KEY_URL]); + $this->assertEquals('Doc 1', $array[MessagePart::KEY_CITATIONS][0][Citation::KEY_TITLE]); + $this->assertEquals(0, $array[MessagePart::KEY_CITATIONS][0][Citation::KEY_START_INDEX]); + $this->assertEquals(15, $array[MessagePart::KEY_CITATIONS][0][Citation::KEY_END_INDEX]); } /** @@ -693,18 +696,29 @@ public function testFromArrayWithCitations(): void MessagePart::KEY_TEXT => 'Text with citations', MessagePart::KEY_CITATIONS => [ [ - Citation::KEY_URI => 'https://example.com/doc1', + Citation::KEY_URL => 'https://example.com/doc1', Citation::KEY_TITLE => 'Doc 1', - ] + Citation::KEY_START_INDEX => 0, + Citation::KEY_END_INDEX => 19, + ], + [ + Citation::KEY_DOCUMENT_INDEX => 3, + Citation::KEY_QUOTED_TEXT => 'a passage', + ], ], ]; $part = MessagePart::fromArray($array); $this->assertNotNull($part->getCitations()); - $this->assertCount(1, $part->getCitations()); - $this->assertEquals('https://example.com/doc1', $part->getCitations()[0]->getUri()); + $this->assertCount(2, $part->getCitations()); + $this->assertEquals('https://example.com/doc1', $part->getCitations()[0]->getUrl()); $this->assertEquals('Doc 1', $part->getCitations()[0]->getTitle()); + $this->assertEquals(0, $part->getCitations()[0]->getStartIndex()); + $this->assertEquals(19, $part->getCitations()[0]->getEndIndex()); + $this->assertNull($part->getCitations()[1]->getUrl()); + $this->assertEquals(3, $part->getCitations()[1]->getDocumentIndex()); + $this->assertEquals('a passage', $part->getCitations()[1]->getQuotedText()); } /** @@ -714,7 +728,9 @@ public function testFromArrayWithCitations(): void */ public function testArrayRoundTripWithCitations(): void { - $citations = [new Citation('https://example.com/doc1', 'Doc 1')]; + $citations = [ + new Citation('https://example.com/doc1', null, 'Doc 1', 0, 10, 'quoted'), + ]; $original = new MessagePart( 'Text with citations', MessagePartChannelEnum::content(), @@ -728,7 +744,11 @@ public function testArrayRoundTripWithCitations(): void $this->assertEquals($original->getText(), $restored->getText()); $this->assertNotNull($restored->getCitations()); $this->assertCount(1, $restored->getCitations()); - $this->assertEquals('https://example.com/doc1', $restored->getCitations()[0]->getUri()); + $this->assertEquals('https://example.com/doc1', $restored->getCitations()[0]->getUrl()); + $this->assertEquals('Doc 1', $restored->getCitations()[0]->getTitle()); + $this->assertEquals(0, $restored->getCitations()[0]->getStartIndex()); + $this->assertEquals(10, $restored->getCitations()[0]->getEndIndex()); + $this->assertEquals('quoted', $restored->getCitations()[0]->getQuotedText()); } /** @@ -738,10 +758,18 @@ public function testArrayRoundTripWithCitations(): void */ public function testCloneClonesCitations(): void { - $citations = [new Citation('https://example.com/doc1')]; + $citations = [new Citation('https://example.com/doc1', null, 'Title', 5, 15)]; $original = new MessagePart('text', null, null, $citations); $cloned = clone $original; $this->assertNotSame($original->getCitations()[0], $cloned->getCitations()[0]); + $this->assertEquals( + $original->getCitations()[0]->getUrl(), + $cloned->getCitations()[0]->getUrl() + ); + $this->assertEquals( + $original->getCitations()[0]->getStartIndex(), + $cloned->getCitations()[0]->getStartIndex() + ); } } From c262568634f405fb821079c4aae8d8dc2b4e5cd7 Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Mon, 7 Sep 2026 01:15:48 +0530 Subject: [PATCH 3/5] feat: enforce non-negative validation for Citation documentIndex and update JSON schema --- src/Messages/DTO/Citation.php | 7 +++++++ tests/unit/Messages/DTO/CitationTest.php | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/Messages/DTO/Citation.php b/src/Messages/DTO/Citation.php index afa97ddf..4d52d7ca 100644 --- a/src/Messages/DTO/Citation.php +++ b/src/Messages/DTO/Citation.php @@ -87,6 +87,12 @@ public function __construct( ?int $endIndex = null, ?string $quotedText = null ) { + if ($documentIndex !== null && $documentIndex < 0) { + throw new InvalidArgumentException( + sprintf('Citation documentIndex must be non-negative, got %d.', $documentIndex) + ); + } + if ($startIndex !== null && $startIndex < 0) { throw new InvalidArgumentException( sprintf('Citation startIndex must be non-negative, got %d.', $startIndex) @@ -205,6 +211,7 @@ public static function getJsonSchema(): array ], self::KEY_DOCUMENT_INDEX => [ 'type' => ['integer', 'null'], + 'minimum' => 0, 'description' => 'The index into the request\'s documents array.', ], self::KEY_TITLE => [ diff --git a/tests/unit/Messages/DTO/CitationTest.php b/tests/unit/Messages/DTO/CitationTest.php index 0c23d7fd..3a50c67b 100644 --- a/tests/unit/Messages/DTO/CitationTest.php +++ b/tests/unit/Messages/DTO/CitationTest.php @@ -111,6 +111,19 @@ public function testCreateWithZeroIndices(): void $this->assertEquals(0, $citation->getEndIndex()); } + /** + * Tests that negative documentIndex throws exception. + * + * @return void + */ + public function testNegativeDocumentIndexThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Citation documentIndex must be non-negative, got -1.'); + + new Citation(null, -1); + } + /** * Tests that negative startIndex throws exception. * @@ -210,6 +223,7 @@ public function testJsonSchemaIndexMinimumConstraint(): void { $schema = Citation::getJsonSchema(); + $this->assertEquals(0, $schema['properties'][Citation::KEY_DOCUMENT_INDEX]['minimum']); $this->assertEquals(0, $schema['properties'][Citation::KEY_START_INDEX]['minimum']); $this->assertEquals(0, $schema['properties'][Citation::KEY_END_INDEX]['minimum']); } From 3b9de6678a6557d3fabfdb605430527726949c98 Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Sun, 20 Sep 2026 17:15:50 +0530 Subject: [PATCH 4/5] feat: add location type discriminator and provider extras slot to Citation --- src/Messages/DTO/Citation.php | 87 +++++++++++- tests/unit/Messages/DTO/CitationTest.php | 162 +++++++++++++++++++++++ 2 files changed, 245 insertions(+), 4 deletions(-) diff --git a/src/Messages/DTO/Citation.php b/src/Messages/DTO/Citation.php index 4d52d7ca..c9bb0b14 100644 --- a/src/Messages/DTO/Citation.php +++ b/src/Messages/DTO/Citation.php @@ -14,27 +14,39 @@ * either a remote URL or an index into the request's documents array. * Offset indices are relative to the owning MessagePart's text property. * + * Providers disagree on what their offsets measure, so `type` records which + * one produced the citation. Vendor-specific leftovers go in `additionalData`. + * * @since n.e.x.t * * @phpstan-type CitationArrayShape array{ + * type?: string|null, * url?: string|null, * documentIndex?: int|null, * title?: string|null, * startIndex?: int|null, * endIndex?: int|null, - * quotedText?: string|null + * quotedText?: string|null, + * additionalData?: array|null * } * * @extends AbstractDataTransferObject */ class Citation extends AbstractDataTransferObject { + public const KEY_TYPE = 'type'; public const KEY_URL = 'url'; public const KEY_DOCUMENT_INDEX = 'documentIndex'; public const KEY_TITLE = 'title'; public const KEY_START_INDEX = 'startIndex'; public const KEY_END_INDEX = 'endIndex'; public const KEY_QUOTED_TEXT = 'quotedText'; + public const KEY_ADDITIONAL_DATA = 'additionalData'; + + /** + * @var string|null The provider's location type, e.g. `char_location`. + */ + private ?string $type; /** * @var string|null The remote source URL. @@ -66,6 +78,11 @@ class Citation extends AbstractDataTransferObject */ private ?string $quotedText; + /** + * @var array|null Vendor-specific leftovers, keyed by name. + */ + private ?array $additionalData; + /** * Constructor. * @@ -77,7 +94,10 @@ class Citation extends AbstractDataTransferObject * @param int|null $startIndex The start byte offset into the owning part's text. * @param int|null $endIndex The end byte offset into the owning part's text. * @param string|null $quotedText The quoted source passage. - * @throws InvalidArgumentException If offsets are negative or misordered. + * @param string|null $type The provider's location type, e.g. `char_location`. + * @param array|null $additionalData Vendor-specific leftovers. + * @throws InvalidArgumentException If offsets are negative or misordered, or if + * $additionalData is not a string-keyed map. */ public function __construct( ?string $url = null, @@ -85,7 +105,9 @@ public function __construct( ?string $title = null, ?int $startIndex = null, ?int $endIndex = null, - ?string $quotedText = null + ?string $quotedText = null, + ?string $type = null, + ?array $additionalData = null ) { if ($documentIndex !== null && $documentIndex < 0) { throw new InvalidArgumentException( @@ -115,12 +137,50 @@ public function __construct( ); } + if ($additionalData !== null) { + foreach (array_keys($additionalData) as $key) { + if (!is_string($key)) { + throw new InvalidArgumentException( + 'Citation additionalData must be a map with string keys.' + ); + } + } + } + + $this->type = $type; $this->url = $url; $this->documentIndex = $documentIndex; $this->title = $title; $this->startIndex = $startIndex; $this->endIndex = $endIndex; $this->quotedText = $quotedText; + $this->additionalData = $additionalData; + } + + /** + * Gets the provider's location type, verbatim. + * + * Tells you which coordinate frame the offsets belong to. + * + * @since n.e.x.t + * + * @return string|null The location type or null if the provider supplied none. + */ + public function getType(): ?string + { + return $this->type; + } + + /** + * Gets vendor-specific leftovers that have no normalized counterpart. + * + * @since n.e.x.t + * + * @return array|null The additional data or null if not set. + */ + public function getAdditionalData(): ?array + { + return $this->additionalData; } /** @@ -205,6 +265,10 @@ public static function getJsonSchema(): array return [ 'type' => 'object', 'properties' => [ + self::KEY_TYPE => [ + 'type' => ['string', 'null'], + 'description' => 'The provider\'s location type, e.g. char_location.', + ], self::KEY_URL => [ 'type' => ['string', 'null'], 'description' => 'The remote source URL.', @@ -232,6 +296,11 @@ public static function getJsonSchema(): array 'type' => ['string', 'null'], 'description' => 'The quoted source passage.', ], + self::KEY_ADDITIONAL_DATA => [ + 'type' => 'object', + 'additionalProperties' => true, + 'description' => 'Vendor-specific leftovers with no normalized counterpart.', + ], ], 'additionalProperties' => false, ]; @@ -248,6 +317,10 @@ public function toArray(): array { $data = []; + if ($this->type !== null) { + $data[self::KEY_TYPE] = $this->type; + } + if ($this->url !== null) { $data[self::KEY_URL] = $this->url; } @@ -272,6 +345,10 @@ public function toArray(): array $data[self::KEY_QUOTED_TEXT] = $this->quotedText; } + if ($this->additionalData !== null) { + $data[self::KEY_ADDITIONAL_DATA] = $this->additionalData; + } + return $data; } @@ -288,7 +365,9 @@ public static function fromArray(array $array): self $array[self::KEY_TITLE] ?? null, $array[self::KEY_START_INDEX] ?? null, $array[self::KEY_END_INDEX] ?? null, - $array[self::KEY_QUOTED_TEXT] ?? null + $array[self::KEY_QUOTED_TEXT] ?? null, + $array[self::KEY_TYPE] ?? null, + $array[self::KEY_ADDITIONAL_DATA] ?? null ); } } diff --git a/tests/unit/Messages/DTO/CitationTest.php b/tests/unit/Messages/DTO/CitationTest.php index 3a50c67b..782df5f0 100644 --- a/tests/unit/Messages/DTO/CitationTest.php +++ b/tests/unit/Messages/DTO/CitationTest.php @@ -443,4 +443,166 @@ public function testArrayRoundTripMinimal(): void $this->assertNull($restored->getEndIndex()); $this->assertNull($restored->getQuotedText()); } + + /** + * Tests that the location type discriminator round-trips verbatim. + * + * @return void + */ + public function testTypeDiscriminatorRoundTrips(): void + { + $citation = new Citation( + 'https://www.example.com/en/', + null, + 'Example Title', + null, + null, + 'The source passage, verbatim.', + 'web_search_result_location' + ); + + $this->assertEquals('web_search_result_location', $citation->getType()); + + $array = $citation->toArray(); + $this->assertSame('web_search_result_location', $array[Citation::KEY_TYPE]); + + $restored = Citation::fromArray($array); + $this->assertEquals('web_search_result_location', $restored->getType()); + $this->assertEquals('The source passage, verbatim.', $restored->getQuotedText()); + } + + /** + * Tests that a document location type is preserved alongside a document index. + * + * @return void + */ + public function testDocumentLocationTypeIsPreserved(): void + { + $citation = new Citation( + null, + 0, + 'My Document', + null, + null, + 'The exact text being cited', + 'char_location' + ); + + $this->assertEquals('char_location', $citation->getType()); + $this->assertEquals(0, $citation->getDocumentIndex()); + } + + /** + * Tests that type defaults to null when the provider supplies none. + * + * @return void + */ + public function testTypeDefaultsToNull(): void + { + $citation = new Citation('https://example.com'); + + $this->assertNull($citation->getType()); + $this->assertArrayNotHasKey(Citation::KEY_TYPE, $citation->toArray()); + } + + /** + * Tests that provider-specific values round-trip through additionalData. + * + * @return void + */ + public function testAdditionalDataRoundTrips(): void + { + $extras = [ + 'encrypted_index' => 'abc123opaqueblob', + 'license' => 'CC-BY-4.0', + ]; + + $citation = new Citation( + 'https://example.com', + null, + null, + null, + null, + null, + 'web_search_result_location', + $extras + ); + + $this->assertSame($extras, $citation->getAdditionalData()); + + $restored = Citation::fromArray($citation->toArray()); + $this->assertSame($extras, $restored->getAdditionalData()); + } + + /** + * Tests that additionalData defaults to null and is omitted when unset. + * + * @return void + */ + public function testAdditionalDataDefaultsToNull(): void + { + $citation = new Citation('https://example.com'); + + $this->assertNull($citation->getAdditionalData()); + $this->assertArrayNotHasKey(Citation::KEY_ADDITIONAL_DATA, $citation->toArray()); + } + + /** + * Tests that additionalData rejects a non-string-keyed array. + * + * @return void + */ + public function testAdditionalDataRejectsIntegerKeys(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('additionalData must be a map with string keys'); + + new Citation( + 'https://example.com', + null, + null, + null, + null, + null, + null, + ['opaque-value'] + ); + } + + /** + * Tests that an empty additionalData map serializes as a JSON object, not an array. + * + * @return void + */ + public function testEmptyAdditionalDataSerializesAsObject(): void + { + $citation = new Citation( + 'https://example.com', + null, + null, + null, + null, + null, + null, + [] + ); + + $json = json_encode($citation); + $this->assertIsString($json); + $this->assertStringContainsString('"additionalData":{}', $json); + } + + /** + * Tests that the new fields are exposed in the JSON schema. + * + * @return void + */ + public function testJsonSchemaExposesTypeAndAdditionalData(): void + { + $schema = Citation::getJsonSchema(); + + $this->assertArrayHasKey(Citation::KEY_TYPE, $schema['properties']); + $this->assertArrayHasKey(Citation::KEY_ADDITIONAL_DATA, $schema['properties']); + $this->assertEquals('object', $schema['properties'][Citation::KEY_ADDITIONAL_DATA]['type']); + } } From 7712fa519433f57626071f1d42d2dc9ce96e7739 Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Sun, 20 Sep 2026 17:16:10 +0530 Subject: [PATCH 5/5] chore: fix since tag --- src/Messages/DTO/MessagePart.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index 8fe6e2ef..398032c9 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -222,7 +222,7 @@ public function getFunctionResponse(): ?FunctionResponse /** * Gets the citations. * - * @since 1.4.0 + * @since n.e.x.t * * @return Citation[]|null The citations or null if not set. */