diff --git a/src/Messages/DTO/Citation.php b/src/Messages/DTO/Citation.php new file mode 100644 index 00000000..c9bb0b14 --- /dev/null +++ b/src/Messages/DTO/Citation.php @@ -0,0 +1,373 @@ +|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. + */ + 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; + + /** + * @var array|null Vendor-specific leftovers, keyed by name. + */ + private ?array $additionalData; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @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. + * @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, + ?int $documentIndex = null, + ?string $title = null, + ?int $startIndex = null, + ?int $endIndex = null, + ?string $quotedText = null, + ?string $type = null, + ?array $additionalData = 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) + ); + } + + 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 + ) + ); + } + + 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; + } + + /** + * 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 document index. + * + * @since n.e.x.t + * + * @return int|null The document index or null if the source is a URL. + */ + public function getDocumentIndex(): ?int + { + return $this->documentIndex; + } + + /** + * Gets the title of the source. + * + * @since n.e.x.t + * + * @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 n.e.x.t + */ + 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.', + ], + self::KEY_DOCUMENT_INDEX => [ + 'type' => ['integer', 'null'], + 'minimum' => 0, + '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.', + ], + self::KEY_ADDITIONAL_DATA => [ + 'type' => 'object', + 'additionalProperties' => true, + 'description' => 'Vendor-specific leftovers with no normalized counterpart.', + ], + ], + 'additionalProperties' => false, + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return CitationArrayShape + */ + 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; + } + + 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; + } + + if ($this->additionalData !== null) { + $data[self::KEY_ADDITIONAL_DATA] = $this->additionalData; + } + + return $data; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + return new self( + $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, + $array[self::KEY_TYPE] ?? null, + $array[self::KEY_ADDITIONAL_DATA] ?? null + ); + } +} diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index 228471be..398032c9 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 n.e.x.t + * + * @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..782df5f0 --- /dev/null +++ b/tests/unit/Messages/DTO/CitationTest.php @@ -0,0 +1,608 @@ +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->assertNull($citation->getUrl()); + $this->assertEquals(2, $citation->getDocumentIndex()); + $this->assertNull($citation->getTitle()); + } + + /** + * 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 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. + * + * @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 testNegativeEndIndexThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Citation endIndex must be non-negative, got -5.'); + + new Citation('https://example.com', null, null, 0, -5); + } + + /** + * 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 + */ + public function testJsonSchema(): void + { + $schema = Citation::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey(Citation::KEY_URL, $schema['properties']); + $this->assertArrayHasKey(Citation::KEY_DOCUMENT_INDEX, $schema['properties']); + $this->assertArrayHasKey(Citation::KEY_TITLE, $schema['properties']); + $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_DOCUMENT_INDEX]['minimum']); + $this->assertEquals(0, $schema['properties'][Citation::KEY_START_INDEX]['minimum']); + $this->assertEquals(0, $schema['properties'][Citation::KEY_END_INDEX]['minimum']); + } + + /** + * Tests toArray with URL only. + * + * @return void + */ + public function testToArrayWithUrlOnly(): void + { + $citation = new Citation('https://example.com/doc'); + $array = $citation->toArray(); + + $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 toArray with document index only. + * + * @return void + */ + public function testToArrayWithDocumentIndexOnly(): void + { + $citation = new Citation(null, 3); + $array = $citation->toArray(); + + $this->assertArrayNotHasKey(Citation::KEY_URL, $array); + $this->assertArrayHasKey(Citation::KEY_DOCUMENT_INDEX, $array); + $this->assertEquals(3, $array[Citation::KEY_DOCUMENT_INDEX]); + } + + /** + * Tests toArray with all fields populated. + * + * @return void + */ + public function testToArrayWithAllFields(): void + { + $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($array); + + $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 document index only. + * + * @return void + */ + public function testFromArrayWithDocumentIndexOnly(): void + { + $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($array); + + $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 with document index. + * + * @return void + */ + public function testArrayRoundTripWithDocumentIndex(): void + { + $original = new Citation(null, 4, 'Internal Doc', 0, 100); + $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->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()); + } + + /** + * 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']); + } +} diff --git a/tests/unit/Messages/DTO/MessagePartTest.php b/tests/unit/Messages/DTO/MessagePartTest.php index de7a4ae0..0e89b7c8 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,143 @@ public function testJsonSchemaIncludesThoughtSignature(): void ); } } + + /** + * Tests creating MessagePart with citations. + * + * @return void + */ + public function testCreateWithCitations(): void + { + $citations = [ + new Citation('https://example.com/doc1', null, 'Doc 1', 0, 20), + new Citation(null, 2, null, 21, 40, 'quoted text'), + ]; + + $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', null, 'Doc 1', 0, 15)]; + $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_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]); + } + + /** + * 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_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(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()); + } + + /** + * Tests round-trip array transformation with citations. + * + * @return void + */ + public function testArrayRoundTripWithCitations(): void + { + $citations = [ + new Citation('https://example.com/doc1', null, 'Doc 1', 0, 10, 'quoted'), + ]; + $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]->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()); + } + + /** + * Tests that cloning MessagePart with citations creates independent copies. + * + * @return void + */ + public function testCloneClonesCitations(): void + { + $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() + ); + } }