diff --git a/README.md b/README.md index 58297b7e..2c7be4ff 100644 --- a/README.md +++ b/README.md @@ -209,8 +209,8 @@ partially support by LLMs like GPT. To leverage this, configure the `#[With]` attribute on the method arguments of your tool: ```php +use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With; use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool; -use PhpLlm\LlmChain\Chain\ToolBox\Attribute\ToolParameter; #[AsTool('my_tool', 'Example tool with parameters requirements.')] final class MyTool @@ -230,7 +230,7 @@ final class MyTool } ``` -See attribute class [With](src/Chain/ToolBox/Attribute/With.php) for all available options. +See attribute class [With](src/Chain/JsonSchema/Attribute/With.php) for all available options. > [!NOTE] > Please be aware, that this is only converted in a JSON Schema for the LLM to respect, but not validated by LLM Chain. diff --git a/composer.json b/composer.json index a9b71b02..8f7ad0e8 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "php": ">=8.2", "oskarstark/enum-helper": "^1.5", "phpdocumentor/reflection-docblock": "^5.4", + "phpstan/phpdoc-parser": "^2.1", "psr/cache": "^3.0", "psr/log": "^3.0", "symfony/clock": "^6.4 || ^7.1", @@ -24,7 +25,7 @@ "symfony/property-access": "^6.4 || ^7.1", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1", - "symfony/type-info": "^6.4 || ^7.1", + "symfony/type-info": "^7.2.3", "symfony/uid": "^6.4 || ^7.1", "webmozart/assert": "^1.11" }, diff --git a/src/Chain/ToolBox/Attribute/With.php b/src/Chain/JsonSchema/Attribute/With.php similarity index 98% rename from src/Chain/ToolBox/Attribute/With.php rename to src/Chain/JsonSchema/Attribute/With.php index 3e0d6d47..1d66059a 100644 --- a/src/Chain/ToolBox/Attribute/With.php +++ b/src/Chain/JsonSchema/Attribute/With.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Chain\ToolBox\Attribute; +namespace PhpLlm\LlmChain\Chain\JsonSchema\Attribute; use Webmozart\Assert\Assert; diff --git a/src/Chain/JsonSchema/DescriptionParser.php b/src/Chain/JsonSchema/DescriptionParser.php new file mode 100644 index 00000000..3759037d --- /dev/null +++ b/src/Chain/JsonSchema/DescriptionParser.php @@ -0,0 +1,49 @@ +fromProperty($reflector); + } + + return $this->fromParameter($reflector); + } + + private function fromProperty(\ReflectionProperty $property): string + { + $comment = $property->getDocComment(); + + if (is_string($comment) && preg_match('/@var\s+[a-zA-Z\\\\]+\s+((.*)(?=\*)|.*)/', $comment, $matches)) { + return trim($matches[1]); + } + + $class = $property->getDeclaringClass(); + if ($class->hasMethod('__construct')) { + return $this->fromParameter( + new \ReflectionParameter([$class->getName(), '__construct'], $property->getName()) + ); + } + + return ''; + } + + private function fromParameter(\ReflectionParameter $parameter): string + { + $comment = $parameter->getDeclaringFunction()->getDocComment(); + if (!$comment) { + return ''; + } + + if (preg_match('/@param\s+\S+\s+\$'.preg_quote($parameter->getName(), '/').'\s+((.*)(?=\*)|.*)/', $comment, $matches)) { + return trim($matches[1]); + } + + return ''; + } +} diff --git a/src/Chain/JsonSchema/Factory.php b/src/Chain/JsonSchema/Factory.php new file mode 100644 index 00000000..3cf620e5 --- /dev/null +++ b/src/Chain/JsonSchema/Factory.php @@ -0,0 +1,175 @@ +, + * const?: string|int|list, + * pattern?: string, + * minLength?: int, + * maxLength?: int, + * minimum?: int, + * maximum?: int, + * multipleOf?: int, + * exclusiveMinimum?: int, + * exclusiveMaximum?: int, + * minItems?: int, + * maxItems?: int, + * uniqueItems?: bool, + * minContains?: int, + * maxContains?: int, + * required?: bool, + * minProperties?: int, + * maxProperties?: int, + * dependentRequired?: bool, + * }>, + * required: list, + * additionalProperties: false, + * } + */ +final readonly class Factory +{ + private TypeResolver $typeResolver; + + public function __construct( + private DescriptionParser $descriptionParser = new DescriptionParser(), + ?TypeResolver $typeResolver = null, + ) { + $this->typeResolver = $typeResolver ?? TypeResolver::create(); + } + + /** + * @return JsonSchema|null + */ + public function buildParameters(string $className, string $methodName): ?array + { + $reflection = new \ReflectionMethod($className, $methodName); + + return $this->convertTypes($reflection->getParameters()); + } + + /** + * @return JsonSchema|null + */ + public function buildProperties(string $className): ?array + { + $reflection = new \ReflectionClass($className); + + return $this->convertTypes($reflection->getProperties()); + } + + /** + * @param list<\ReflectionProperty|\ReflectionParameter> $elements + * + * @return JsonSchema|null + */ + private function convertTypes(array $elements): ?array + { + if (0 === count($elements)) { + return null; + } + + $result = [ + 'type' => 'object', + 'properties' => [], + 'required' => [], + 'additionalProperties' => false, + ]; + + foreach ($elements as $element) { + $name = $element->getName(); + $type = $this->typeResolver->resolve($element); + $schema = $this->getTypeSchema($type); + + if ($type->isNullable()) { + $schema['type'] = [$schema['type'], 'null']; + } else { + $result['required'][] = $name; + } + + $description = $this->descriptionParser->getDescription($element); + if ('' !== $description) { + $schema['description'] = $description; + } + + // Check for ToolParameter attributes + $attributes = $element->getAttributes(With::class); + if (count($attributes) > 0) { + $attributeState = array_filter((array) $attributes[0]->newInstance(), fn ($value) => null !== $value); + $schema = array_merge($schema, $attributeState); + } + + $result['properties'][$name] = $schema; + } + + return $result; + } + + /** + * @return array + */ + private function getTypeSchema(Type $type): array + { + switch (true) { + case $type->isIdentifiedBy(TypeIdentifier::INT): + return ['type' => 'integer']; + + case $type->isIdentifiedBy(TypeIdentifier::FLOAT): + return ['type' => 'number']; + + case $type->isIdentifiedBy(TypeIdentifier::BOOL): + return ['type' => 'boolean']; + + case $type->isIdentifiedBy(TypeIdentifier::ARRAY): + assert($type instanceof CollectionType); + $collectionValueType = $type->getCollectionValueType(); + + if ($collectionValueType->isIdentifiedBy(TypeIdentifier::OBJECT)) { + assert($collectionValueType instanceof ObjectType); + + return [ + 'type' => 'array', + 'items' => $this->buildProperties($collectionValueType->getClassName()), + ]; + } + + return [ + 'type' => 'array', + 'items' => $this->getTypeSchema($collectionValueType), + ]; + + case $type->isIdentifiedBy(TypeIdentifier::OBJECT): + if ($type instanceof BuiltinType) { + throw new \InvalidArgumentException('Cannot build schema from plain object type.'); + } + assert($type instanceof ObjectType); + if (in_array($type->getClassName(), ['DateTime', 'DateTimeImmutable', 'DateTimeInterface'], true)) { + return ['type' => 'string', 'format' => 'date-time']; + } else { + // Recursively build the schema for an object type + return $this->buildProperties($type->getClassName()); + } + + // no break + case $type->isIdentifiedBy(TypeIdentifier::STRING): + default: + // Fallback to string for any unhandled types + return ['type' => 'string']; + } + } +} diff --git a/src/Chain/StructuredOutput/ResponseFormatFactory.php b/src/Chain/StructuredOutput/ResponseFormatFactory.php index 328c954d..99d57c9c 100644 --- a/src/Chain/StructuredOutput/ResponseFormatFactory.php +++ b/src/Chain/StructuredOutput/ResponseFormatFactory.php @@ -4,12 +4,14 @@ namespace PhpLlm\LlmChain\Chain\StructuredOutput; +use PhpLlm\LlmChain\Chain\JsonSchema\Factory; + use function Symfony\Component\String\u; final readonly class ResponseFormatFactory implements ResponseFormatFactoryInterface { public function __construct( - private SchemaFactory $schemaFactory = new SchemaFactory(), + private Factory $schemaFactory = new Factory(), ) { } @@ -19,7 +21,7 @@ public function create(string $responseClass): array 'type' => 'json_schema', 'json_schema' => [ 'name' => u($responseClass)->afterLast('\\')->toString(), - 'schema' => $this->schemaFactory->buildSchema($responseClass), + 'schema' => $this->schemaFactory->buildProperties($responseClass), 'strict' => true, ], ]; diff --git a/src/Chain/StructuredOutput/SchemaFactory.php b/src/Chain/StructuredOutput/SchemaFactory.php deleted file mode 100644 index b8781f70..00000000 --- a/src/Chain/StructuredOutput/SchemaFactory.php +++ /dev/null @@ -1,132 +0,0 @@ -propertyInfo = new PropertyInfoExtractor( - [$reflectionExtractor], - [$phpDocExtractor, $reflectionExtractor], - [$phpDocExtractor], - [$reflectionExtractor], - ); - } - } - - /** - * @param class-string $className - * - * @return array - */ - public function buildSchema(string $className): array - { - $reflectionClass = new \ReflectionClass($className); - $properties = $reflectionClass->getProperties(); - - $schema = [ - 'title' => $reflectionClass->getShortName(), - 'type' => 'object', - 'properties' => [], - 'required' => [], - 'additionalProperties' => false, - ]; - - foreach ($properties as $property) { - $propertyName = $property->getName(); - $types = $this->propertyInfo->getTypes($className, $propertyName); - $description = $this->propertyInfo->getShortDescription($className, $propertyName); - - if (empty($types)) { - // Skip if no type info is available - continue; - } - - // Assume the first type is the main type (ignore union types for simplicity) - $type = $types[0]; - $propertySchema = $this->getTypeSchema($type); - - // Handle nullable types - if (!is_array($propertySchema['type']) && $type->isNullable()) { - $propertySchema['type'] = [$propertySchema['type'], 'null']; - } - - // Add description if available - if ($description) { - $propertySchema['description'] = $description; - } - - // Add property schema to main schema - $schema['properties'][$propertyName] = $propertySchema; - - // If the property does not allow null, mark it as required - if (!$type->isNullable()) { - $schema['required'][] = $propertyName; - } - } - - return $schema; - } - - /** - * @return array - */ - private function getTypeSchema(Type $type): array - { - switch ($type->getBuiltinType()) { - case Type::BUILTIN_TYPE_INT: - return ['type' => 'integer']; - - case Type::BUILTIN_TYPE_FLOAT: - return ['type' => 'number']; - - case Type::BUILTIN_TYPE_BOOL: - return ['type' => 'boolean']; - - case Type::BUILTIN_TYPE_ARRAY: - $collectionValueTypes = $type->getCollectionValueTypes(); - - if (!empty($collectionValueTypes) && Type::BUILTIN_TYPE_OBJECT === $collectionValueTypes[0]->getBuiltinType()) { - return [ - 'type' => 'array', - 'items' => $this->buildSchema($collectionValueTypes[0]->getClassName()), - ]; - } elseif (!empty($collectionValueTypes)) { - return [ - 'type' => 'array', - 'items' => $this->getTypeSchema($collectionValueTypes[0]), - ]; - } - - // Fallback for arrays - return ['type' => 'array', 'items' => ['type' => 'string']]; - - case Type::BUILTIN_TYPE_OBJECT: - if (\DateTimeInterface::class === $type->getClassName()) { - return ['type' => 'string', 'format' => 'date-time']; - } else { - // Recursively build the schema for an object type - return $this->buildSchema($type->getClassName()); - } - - // no break - case Type::BUILTIN_TYPE_STRING: - default: - // Fallback to string for any unhandled types - return ['type' => 'string']; - } - } -} diff --git a/src/Chain/ToolBox/Metadata.php b/src/Chain/ToolBox/Metadata.php index caca3306..e118454e 100644 --- a/src/Chain/ToolBox/Metadata.php +++ b/src/Chain/ToolBox/Metadata.php @@ -4,13 +4,15 @@ namespace PhpLlm\LlmChain\Chain\ToolBox; +use PhpLlm\LlmChain\Chain\JsonSchema\Factory; + /** - * @phpstan-import-type ParameterDefinition from ParameterAnalyzer + * @phpstan-import-type JsonSchema from Factory */ final readonly class Metadata implements \JsonSerializable { /** - * @param ParameterDefinition|null $parameters + * @param JsonSchema|null $parameters */ public function __construct( public string $className, @@ -27,7 +29,7 @@ public function __construct( * function: array{ * name: string, * description: string, - * parameters?: ParameterDefinition + * parameters?: JsonSchema * } * } */ diff --git a/src/Chain/ToolBox/ParameterAnalyzer.php b/src/Chain/ToolBox/ParameterAnalyzer.php deleted file mode 100644 index 5782335b..00000000 --- a/src/Chain/ToolBox/ParameterAnalyzer.php +++ /dev/null @@ -1,110 +0,0 @@ -, - * const?: string|int|list, - * pattern?: string, - * minLength?: int, - * maxLength?: int, - * minimum?: int, - * maximum?: int, - * multipleOf?: int, - * exclusiveMinimum?: int, - * exclusiveMaximum?: int, - * minItems?: int, - * maxItems?: int, - * uniqueItems?: bool, - * minContains?: int, - * maxContains?: int, - * required?: bool, - * minProperties?: int, - * maxProperties?: int, - * dependentRequired?: bool, - * }>, - * required: list, - * } - */ -final class ParameterAnalyzer -{ - /** - * @return ParameterDefinition|null - */ - public function getDefinition(string $className, string $methodName): ?array - { - try { - $reflection = new \ReflectionMethod($className, $methodName); - } catch (\ReflectionException) { - throw ToolConfigurationException::invalidMethod($className, $methodName); - } - $parameters = $reflection->getParameters(); - - if (0 === count($parameters)) { - return null; - } - - $result = [ - 'type' => 'object', - 'properties' => [], - 'required' => [], - ]; - - foreach ($parameters as $parameter) { - $paramName = $parameter->getName(); - $paramType = $parameter->getType(); - $paramType = $paramType instanceof \ReflectionNamedType ? $paramType->getName() : 'mixed'; - - $paramType = match ($paramType) { - 'int' => 'integer', - 'float' => 'number', - default => $paramType, - }; - - if (!$parameter->isOptional()) { - $result['required'][] = $paramName; - } - - $property = [ - 'type' => $paramType, - 'description' => $this->getParameterDescription($reflection, $paramName), - ]; - - // Check for ToolParameter attributes - $attributes = $parameter->getAttributes(With::class); - if (count($attributes) > 0) { - $attributeState = array_filter((array) $attributes[0]->newInstance(), fn ($value) => null !== $value); - $property = array_merge($property, $attributeState); - } - - $result['properties'][$paramName] = $property; - } - - return $result; - } - - private function getParameterDescription(\ReflectionMethod $method, string $paramName): string - { - $docComment = $method->getDocComment(); - if (!$docComment) { - return ''; - } - - $pattern = '/@param\s+\S+\s+\$'.preg_quote($paramName, '/').'\s+((.*)(?=\*)|.*)/'; - if (preg_match($pattern, $docComment, $matches)) { - return trim($matches[1]); - } - - return ''; - } -} diff --git a/src/Chain/ToolBox/Tool/OpenMeteo.php b/src/Chain/ToolBox/Tool/OpenMeteo.php index 5e9ab466..24aa724c 100644 --- a/src/Chain/ToolBox/Tool/OpenMeteo.php +++ b/src/Chain/ToolBox/Tool/OpenMeteo.php @@ -4,8 +4,8 @@ namespace PhpLlm\LlmChain\Chain\ToolBox\Tool; +use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With; use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool; -use PhpLlm\LlmChain\Chain\ToolBox\Attribute\With; use Symfony\Contracts\HttpClient\HttpClientInterface; #[AsTool(name: 'weather_current', description: 'get current weather for a location', method: 'current')] diff --git a/src/Chain/ToolBox/ToolAnalyzer.php b/src/Chain/ToolBox/ToolAnalyzer.php index 3a41065c..30fe46c0 100644 --- a/src/Chain/ToolBox/ToolAnalyzer.php +++ b/src/Chain/ToolBox/ToolAnalyzer.php @@ -4,13 +4,14 @@ namespace PhpLlm\LlmChain\Chain\ToolBox; +use PhpLlm\LlmChain\Chain\JsonSchema\Factory; use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool; use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolConfigurationException; final readonly class ToolAnalyzer { public function __construct( - private ParameterAnalyzer $parameterAnalyzer = new ParameterAnalyzer(), + private Factory $factory = new Factory(), ) { } @@ -35,12 +36,16 @@ public function getMetadata(string $className): iterable private function convertAttribute(string $className, AsTool $attribute): Metadata { - return new Metadata( - $className, - $attribute->name, - $attribute->description, - $attribute->method, - $this->parameterAnalyzer->getDefinition($className, $attribute->method) - ); + try { + return new Metadata( + $className, + $attribute->name, + $attribute->description, + $attribute->method, + $this->factory->buildParameters($className, $attribute->method) + ); + } catch (\ReflectionException) { + throw ToolConfigurationException::invalidMethod($className, $attribute->method); + } } } diff --git a/tests/Chain/JsonSchema/DescriptionParserTest.php b/tests/Chain/JsonSchema/DescriptionParserTest.php new file mode 100644 index 00000000..e178c84e --- /dev/null +++ b/tests/Chain/JsonSchema/DescriptionParserTest.php @@ -0,0 +1,126 @@ +getDescription($property); + + self::assertSame('', $actual); + } + + #[Test] + public function fromPropertyWithDocBlock(): void + { + $property = new \ReflectionProperty(User::class, 'name'); + + $actual = (new DescriptionParser())->getDescription($property); + + self::assertSame('The name of the user in lowercase', $actual); + } + + #[Test] + public function fromPropertyWithConstructorDocBlock(): void + { + $property = new \ReflectionProperty(UserWithConstructor::class, 'name'); + + $actual = (new DescriptionParser())->getDescription($property); + + self::assertSame('The name of the user in lowercase', $actual); + } + + #[Test] + public function fromParameterWithoutDocBlock(): void + { + $parameter = new \ReflectionParameter([ToolWithoutDocs::class, 'bar'], 'text'); + + $actual = (new DescriptionParser())->getDescription($parameter); + + self::assertSame('', $actual); + } + + #[Test] + public function fromParameterWithDocBlock(): void + { + $parameter = new \ReflectionParameter([ToolRequiredParams::class, 'bar'], 'text'); + + $actual = (new DescriptionParser())->getDescription($parameter); + + self::assertSame('The text given to the tool', $actual); + } + + #[Test] + #[DataProvider('provideMethodDescriptionCases')] + public function fromParameterWithDocs(string $comment, string $expected): void + { + $method = self::createMock(\ReflectionMethod::class); + $method->method('getDocComment')->willReturn($comment); + $parameter = self::createMock(\ReflectionParameter::class); + $parameter->method('getDeclaringFunction')->willReturn($method); + $parameter->method('getName')->willReturn('myParam'); + + $actual = (new DescriptionParser())->getDescription($parameter); + + self::assertSame($expected, $actual); + } + + public static function provideMethodDescriptionCases(): \Generator + { + yield 'empty doc block' => [ + 'comment' => '', + 'expected' => '', + ]; + + yield 'single line doc block with description' => [ + 'comment' => '/** @param string $myParam The description */', + 'expected' => 'The description', + ]; + + yield 'multi line doc block with description and other tags' => [ + 'comment' => <<<'TEXT' + /** + * @param string $myParam The description + * @return void + */ + TEXT, + 'expected' => 'The description', + ]; + + yield 'multi line doc block with multiple parameters' => [ + 'comment' => <<<'TEXT' + /** + * @param string $myParam The description + * @param string $anotherParam The wrong description + */ + TEXT, + 'expected' => 'The description', + ]; + + yield 'multi line doc block with parameter that is not searched for' => [ + 'comment' => <<<'TEXT' + /** + * @param string $unknownParam The description + */ + TEXT, + 'expected' => '', + ]; + } +} diff --git a/tests/Chain/ToolBox/ParameterAnalyzerTest.php b/tests/Chain/JsonSchema/FactoryTest.php similarity index 53% rename from tests/Chain/ToolBox/ParameterAnalyzerTest.php rename to tests/Chain/JsonSchema/FactoryTest.php index e7a18903..fa1732d5 100644 --- a/tests/Chain/ToolBox/ParameterAnalyzerTest.php +++ b/tests/Chain/JsonSchema/FactoryTest.php @@ -2,40 +2,44 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Chain\ToolBox; - -use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool; -use PhpLlm\LlmChain\Chain\ToolBox\Attribute\With; -use PhpLlm\LlmChain\Chain\ToolBox\Metadata; -use PhpLlm\LlmChain\Chain\ToolBox\ParameterAnalyzer; +namespace PhpLlm\LlmChain\Tests\Chain\JsonSchema; + +use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With; +use PhpLlm\LlmChain\Chain\JsonSchema\DescriptionParser; +use PhpLlm\LlmChain\Chain\JsonSchema\Factory; +use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\MathReasoning; +use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\Step; +use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\User; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolNoParams; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolOptionalParam; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolRequiredParams; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolWithToolParameterAttribute; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -#[CoversClass(ParameterAnalyzer::class)] -#[UsesClass(AsTool::class)] -#[UsesClass(Metadata::class)] -#[UsesClass(ParameterAnalyzer::class)] +#[CoversClass(Factory::class)] #[UsesClass(With::class)] -final class ParameterAnalyzerTest extends TestCase +#[UsesClass(DescriptionParser::class)] +final class FactoryTest extends TestCase { - private ParameterAnalyzer $analyzer; + private Factory $factory; protected function setUp(): void { - $this->analyzer = new ParameterAnalyzer(); + $this->factory = new Factory(); + } + + protected function tearDown(): void + { + unset($this->factory); } #[Test] - public function detectParameterDefinitionRequired(): void + public function buildParametersDefinitionRequired(): void { - $actual = $this->analyzer->getDefinition(ToolRequiredParams::class, 'bar'); + $actual = $this->factory->buildParameters(ToolRequiredParams::class, 'bar'); $expected = [ 'type' => 'object', 'properties' => [ @@ -48,19 +52,17 @@ public function detectParameterDefinitionRequired(): void 'description' => 'A number given to the tool', ], ], - 'required' => [ - 'text', - 'number', - ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, ]; self::assertSame($expected, $actual); } #[Test] - public function detectParameterDefinitionRequiredWithAdditionalToolParameterAttribute(): void + public function buildParametersDefinitionRequiredWithAdditionalToolParameterAttribute(): void { - $actual = $this->analyzer->getDefinition(ToolWithToolParameterAttribute::class, '__invoke'); + $actual = $this->factory->buildParameters(ToolWithToolParameterAttribute::class, '__invoke'); $expected = [ 'type' => 'object', 'properties' => [ @@ -102,6 +104,7 @@ public function detectParameterDefinitionRequiredWithAdditionalToolParameterAttr ], 'products' => [ 'type' => 'array', + 'items' => ['type' => 'string'], 'description' => 'The products given to the tool', 'minItems' => 1, 'maxItems' => 10, @@ -110,7 +113,7 @@ public function detectParameterDefinitionRequiredWithAdditionalToolParameterAttr 'maxContains' => 10, ], 'shippingAddress' => [ - 'type' => 'object', + 'type' => 'string', 'description' => 'The shipping address given to the tool', 'required' => true, 'minProperties' => 1, @@ -128,15 +131,16 @@ public function detectParameterDefinitionRequiredWithAdditionalToolParameterAttr 'products', 'shippingAddress', ], + 'additionalProperties' => false, ]; self::assertSame($expected, $actual); } #[Test] - public function detectParameterDefinitionOptional(): void + public function buildParametersDefinitionOptional(): void { - $actual = $this->analyzer->getDefinition(ToolOptionalParam::class, 'bar'); + $actual = $this->factory->buildParameters(ToolOptionalParam::class, 'bar'); $expected = [ 'type' => 'object', 'properties' => [ @@ -149,98 +153,92 @@ public function detectParameterDefinitionOptional(): void 'description' => 'A number given to the tool', ], ], - 'required' => [ - 'text', - ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, ]; self::assertSame($expected, $actual); } #[Test] - public function detectParameterDefinitionNone(): void + public function buildParametersDefinitionNone(): void { - $actual = $this->analyzer->getDefinition(ToolNoParams::class, '__invoke'); + $actual = $this->factory->buildParameters(ToolNoParams::class, '__invoke'); self::assertNull($actual); } #[Test] - public function getParameterDescriptionWithoutDocBlock(): void + public function buildPropertiesForUserClass(): void { - $targetMethod = self::createStub(\ReflectionMethod::class); - $targetMethod->method('getDocComment')->willReturn(false); - - $methodToTest = new \ReflectionMethod(ParameterAnalyzer::class, 'getParameterDescription'); - - self::assertSame( - '', - $methodToTest->invoke( - $this->analyzer, - $targetMethod, - 'myParam', - ) - ); - } + $expected = [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the user in lowercase', + ], + 'createdAt' => [ + 'type' => 'string', + 'format' => 'date-time', + ], + 'isActive' => ['type' => 'boolean'], + 'age' => ['type' => ['integer', 'null']], + ], + 'required' => ['id', 'name', 'createdAt', 'isActive'], + 'additionalProperties' => false, + ]; - #[Test] - #[DataProvider('provideGetParameterDescriptionCases')] - public function getParameterDescriptionWithDocs(string $docComment, string $expectedResult): void - { - $targetMethod = self::createStub(\ReflectionMethod::class); - $targetMethod->method('getDocComment')->willReturn($docComment); - - $methodToTest = new \ReflectionMethod(ParameterAnalyzer::class, 'getParameterDescription'); - - self::assertSame( - $expectedResult, - $methodToTest->invoke( - $this->analyzer, - $targetMethod, - 'myParam', - ) - ); + $actual = $this->factory->buildProperties(User::class); + + self::assertSame($expected, $actual); } - public static function provideGetParameterDescriptionCases(): \Generator + #[Test] + public function buildPropertiesForMathReasoningClass(): void { - yield 'empty doc block' => [ - 'docComment' => '', - 'expectedResult' => '', + $expected = [ + 'type' => 'object', + 'properties' => [ + 'steps' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'explanation' => ['type' => 'string'], + 'output' => ['type' => 'string'], + ], + 'required' => ['explanation', 'output'], + 'additionalProperties' => false, + ], + ], + 'finalAnswer' => ['type' => 'string'], + ], + 'required' => ['steps', 'finalAnswer'], + 'additionalProperties' => false, ]; - yield 'single line doc block with description' => [ - 'docComment' => '/** @param string $myParam The description */', - 'expectedResult' => 'The description', - ]; + $actual = $this->factory->buildProperties(MathReasoning::class); - yield 'multi line doc block with description and other tags' => [ - 'docComment' => <<<'TEXT' - /** - * @param string $myParam The description - * @return void - */ - TEXT, - 'expectedResult' => 'The description', - ]; + self::assertSame($expected, $actual); + } - yield 'multi line doc block with multiple parameters' => [ - 'docComment' => <<<'TEXT' - /** - * @param string $myParam The description - * @param string $anotherParam The wrong description - */ - TEXT, - 'expectedResult' => 'The description', + #[Test] + public function buildPropertiesForStepClass(): void + { + $expected = [ + 'type' => 'object', + 'properties' => [ + 'explanation' => ['type' => 'string'], + 'output' => ['type' => 'string'], + ], + 'required' => ['explanation', 'output'], + 'additionalProperties' => false, ]; - yield 'multi line doc block with parameter that is not searched for' => [ - 'docComment' => <<<'TEXT' - /** - * @param string $unknownParam The description - */ - TEXT, - 'expectedResult' => '', - ]; + $actual = $this->factory->buildProperties(Step::class); + + self::assertSame($expected, $actual); } } diff --git a/tests/Chain/StructuredOutput/ResponseFormatFactoryTest.php b/tests/Chain/StructuredOutput/ResponseFormatFactoryTest.php index 51825b52..3f80e297 100644 --- a/tests/Chain/StructuredOutput/ResponseFormatFactoryTest.php +++ b/tests/Chain/StructuredOutput/ResponseFormatFactoryTest.php @@ -4,8 +4,9 @@ namespace PhpLlm\LlmChain\Tests\Chain\StructuredOutput; +use PhpLlm\LlmChain\Chain\JsonSchema\DescriptionParser; +use PhpLlm\LlmChain\Chain\JsonSchema\Factory; use PhpLlm\LlmChain\Chain\StructuredOutput\ResponseFormatFactory; -use PhpLlm\LlmChain\Chain\StructuredOutput\SchemaFactory; use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\User; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; @@ -13,7 +14,8 @@ use PHPUnit\Framework\TestCase; #[CoversClass(ResponseFormatFactory::class)] -#[UsesClass(SchemaFactory::class)] +#[UsesClass(DescriptionParser::class)] +#[UsesClass(Factory::class)] final class ResponseFormatFactoryTest extends TestCase { #[Test] @@ -24,7 +26,6 @@ public function create(): void 'json_schema' => [ 'name' => 'User', 'schema' => [ - 'title' => 'User', 'type' => 'object', 'properties' => [ 'id' => ['type' => 'integer'], diff --git a/tests/Chain/StructuredOutput/SchemaFactoryTest.php b/tests/Chain/StructuredOutput/SchemaFactoryTest.php deleted file mode 100644 index 2b4ace56..00000000 --- a/tests/Chain/StructuredOutput/SchemaFactoryTest.php +++ /dev/null @@ -1,102 +0,0 @@ -schemaFactory = new SchemaFactory(); - } - - #[Test] - public function buildSchemaForUserClass(): void - { - $expected = [ - 'title' => 'User', - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'integer'], - 'name' => [ - 'type' => 'string', - 'description' => 'The name of the user in lowercase', - ], - 'createdAt' => [ - 'type' => 'string', - 'format' => 'date-time', - ], - 'isActive' => ['type' => 'boolean'], - 'age' => ['type' => ['integer', 'null']], - ], - 'required' => ['id', 'name', 'createdAt', 'isActive'], - 'additionalProperties' => false, - ]; - - $actual = $this->schemaFactory->buildSchema(User::class); - - self::assertSame($expected, $actual); - } - - #[Test] - public function buildSchemaForMathReasoningClass(): void - { - $expected = [ - 'title' => 'MathReasoning', - 'type' => 'object', - 'properties' => [ - 'steps' => [ - 'type' => 'array', - 'items' => [ - 'title' => 'Step', - 'type' => 'object', - 'properties' => [ - 'explanation' => ['type' => 'string'], - 'output' => ['type' => 'string'], - ], - 'required' => ['explanation', 'output'], - 'additionalProperties' => false, - ], - ], - 'finalAnswer' => ['type' => 'string'], - ], - 'required' => ['steps', 'finalAnswer'], - 'additionalProperties' => false, - ]; - - $actual = $this->schemaFactory->buildSchema(MathReasoning::class); - - self::assertSame($expected, $actual); - } - - #[Test] - public function buildSchemaForStepClass(): void - { - $expected = [ - 'title' => 'Step', - 'type' => 'object', - 'properties' => [ - 'explanation' => ['type' => 'string'], - 'output' => ['type' => 'string'], - ], - 'required' => ['explanation', 'output'], - 'additionalProperties' => false, - ]; - - $actual = $this->schemaFactory->buildSchema(Step::class); - - self::assertSame($expected, $actual); - } -} diff --git a/tests/Chain/ToolBox/Attribute/ToolParameterTest.php b/tests/Chain/ToolBox/Attribute/ToolParameterTest.php index 50a6d417..aca82ac6 100644 --- a/tests/Chain/ToolBox/Attribute/ToolParameterTest.php +++ b/tests/Chain/ToolBox/Attribute/ToolParameterTest.php @@ -4,7 +4,7 @@ namespace PhpLlm\LlmChain\Tests\Chain\ToolBox\Attribute; -use PhpLlm\LlmChain\Chain\ToolBox\Attribute\With; +use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; diff --git a/tests/Chain/ToolBox/ToolAnalyzerTest.php b/tests/Chain/ToolBox/ToolAnalyzerTest.php index fc2952ac..6115ca8a 100644 --- a/tests/Chain/ToolBox/ToolAnalyzerTest.php +++ b/tests/Chain/ToolBox/ToolAnalyzerTest.php @@ -4,10 +4,11 @@ namespace PhpLlm\LlmChain\Tests\Chain\ToolBox; +use PhpLlm\LlmChain\Chain\JsonSchema\DescriptionParser; +use PhpLlm\LlmChain\Chain\JsonSchema\Factory; use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool; use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolConfigurationException; use PhpLlm\LlmChain\Chain\ToolBox\Metadata; -use PhpLlm\LlmChain\Chain\ToolBox\ParameterAnalyzer; use PhpLlm\LlmChain\Chain\ToolBox\ToolAnalyzer; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolMultiple; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolRequiredParams; @@ -20,7 +21,8 @@ #[CoversClass(ToolAnalyzer::class)] #[UsesClass(AsTool::class)] #[UsesClass(Metadata::class)] -#[UsesClass(ParameterAnalyzer::class)] +#[UsesClass(Factory::class)] +#[UsesClass(DescriptionParser::class)] #[UsesClass(ToolConfigurationException::class)] final class ToolAnalyzerTest extends TestCase { @@ -63,6 +65,7 @@ className: ToolRequiredParams::class, ], ], 'required' => ['text', 'number'], + 'additionalProperties' => false, ], ); } @@ -91,6 +94,7 @@ className: ToolMultiple::class, ], ], 'required' => ['world'], + 'additionalProperties' => false, ], ); @@ -113,6 +117,7 @@ className: ToolMultiple::class, ], ], 'required' => ['text', 'number'], + 'additionalProperties' => false, ], ); } diff --git a/tests/Chain/ToolBox/ToolBoxTest.php b/tests/Chain/ToolBox/ToolBoxTest.php index ca75d285..71e5536e 100644 --- a/tests/Chain/ToolBox/ToolBoxTest.php +++ b/tests/Chain/ToolBox/ToolBoxTest.php @@ -4,12 +4,13 @@ namespace PhpLlm\LlmChain\Tests\Chain\ToolBox; +use PhpLlm\LlmChain\Chain\JsonSchema\DescriptionParser; +use PhpLlm\LlmChain\Chain\JsonSchema\Factory; use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool; use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolConfigurationException; use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolExecutionException; use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolNotFoundException; use PhpLlm\LlmChain\Chain\ToolBox\Metadata; -use PhpLlm\LlmChain\Chain\ToolBox\ParameterAnalyzer; use PhpLlm\LlmChain\Chain\ToolBox\ToolAnalyzer; use PhpLlm\LlmChain\Chain\ToolBox\ToolBox; use PhpLlm\LlmChain\Model\Response\ToolCall; @@ -28,8 +29,9 @@ #[UsesClass(ToolCall::class)] #[UsesClass(AsTool::class)] #[UsesClass(Metadata::class)] -#[UsesClass(ParameterAnalyzer::class)] #[UsesClass(ToolAnalyzer::class)] +#[UsesClass(Factory::class)] +#[UsesClass(DescriptionParser::class)] #[UsesClass(ToolConfigurationException::class)] #[UsesClass(ToolNotFoundException::class)] #[UsesClass(ToolExecutionException::class)] @@ -69,10 +71,8 @@ public function toolsMap(): void 'description' => 'A number given to the tool', ], ], - 'required' => [ - 'text', - 'number', - ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, ], ], ], @@ -93,9 +93,8 @@ public function toolsMap(): void 'description' => 'A number given to the tool', ], ], - 'required' => [ - 'text', - ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, ], ], ], diff --git a/tests/Fixture/StructuredOutput/UserWithConstructor.php b/tests/Fixture/StructuredOutput/UserWithConstructor.php new file mode 100644 index 00000000..a64b661c --- /dev/null +++ b/tests/Fixture/StructuredOutput/UserWithConstructor.php @@ -0,0 +1,20 @@ + $ids + */ + public function __invoke(array $urls, array $ids): string + { + return 'Hello world!'; + } +} diff --git a/tests/Fixture/Tool/ToolWithToolParameterAttribute.php b/tests/Fixture/Tool/ToolWithToolParameterAttribute.php index 35069975..84c5f612 100644 --- a/tests/Fixture/Tool/ToolWithToolParameterAttribute.php +++ b/tests/Fixture/Tool/ToolWithToolParameterAttribute.php @@ -4,8 +4,8 @@ namespace PhpLlm\LlmChain\Tests\Fixture\Tool; +use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With; use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool; -use PhpLlm\LlmChain\Chain\ToolBox\Attribute\With; #[AsTool('tool_with_ToolParameter_attribute', 'A tool which has a parameter with described with #[ToolParameter] attribute')] final class ToolWithToolParameterAttribute @@ -18,7 +18,7 @@ final class ToolWithToolParameterAttribute * @param string $text The text given to the tool * @param int $number The number given to the tool * @param array $products The products given to the tool - * @param object $shippingAddress The shipping address given to the tool + * @param string $shippingAddress The shipping address given to the tool */ public function __invoke( #[With(enum: ['dog', 'cat', 'bird'])] @@ -57,7 +57,7 @@ public function __invoke( maxProperties: 10, dependentRequired: true, )] - object $shippingAddress, + string $shippingAddress, ): string { return 'Hello, World!'; } diff --git a/tests/Fixture/Tool/ToolWithoutDocs.php b/tests/Fixture/Tool/ToolWithoutDocs.php new file mode 100644 index 00000000..c20bcdb1 --- /dev/null +++ b/tests/Fixture/Tool/ToolWithoutDocs.php @@ -0,0 +1,16 @@ +