Skip to content

Commit 5f39ef1

Browse files
committed
refactor: centralize json schema generation
1 parent 374bf2a commit 5f39ef1

24 files changed

+724
-522
lines changed

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,10 @@ LLM Chain generates a JSON Schema representation for all tools in the `ToolBox`
200200
method arguments and param comments in the doc block. Additionally, JSON Schema support validation rules, which are
201201
partially support by LLMs like GPT.
202202

203-
To leverage this, configure the `#[ToolParameter]` attribute on the method arguments of your tool:
203+
To leverage this, configure the `#[With]` attribute on the method arguments of your tool:
204204
```php
205-
use PhpLlm\LlmChain\ToolBox\Attribute\AsTool;
206-
use PhpLlm\LlmChain\ToolBox\Attribute\ToolParameter;
205+
use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With;
206+
use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool;
207207

208208
#[AsTool('my_tool', 'Example tool with parameters requirements.')]
209209
final class MyTool
@@ -213,17 +213,17 @@ final class MyTool
213213
* @param int $number The number of an object
214214
*/
215215
public function __invoke(
216-
#[ToolParameter(pattern: '/([a-z0-1]){5}/')]
216+
#[With(pattern: '/([a-z0-1]){5}/')]
217217
string $name,
218-
#[ToolParameter(minimum: 0, maximum: 10)]
218+
#[With(minimum: 0, maximum: 10)]
219219
int $number,
220220
): string {
221221
// ...
222222
}
223223
}
224224
```
225225

226-
See attribute class [ToolParameter](src/Chain/ToolBox/Attribute/ToolParameter.php) for all available options.
226+
See attribute class [With](src/Chain/JsonSchema/Attribute/With.php) for all available options.
227227

228228
> [!NOTE]
229229
> Please be aware, that this is only converted in a JSON Schema for the LLM to respect, but not validated by LLM Chain.

src/Chain/ToolBox/Attribute/ToolParameter.php renamed to src/Chain/JsonSchema/Attribute/With.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
declare(strict_types=1);
44

5-
namespace PhpLlm\LlmChain\Chain\ToolBox\Attribute;
5+
namespace PhpLlm\LlmChain\Chain\JsonSchema\Attribute;
66

77
use Webmozart\Assert\Assert;
88

99
#[\Attribute(\Attribute::TARGET_PARAMETER)]
10-
final readonly class ToolParameter
10+
final readonly class With
1111
{
1212
/**
1313
* @param string[]|null $enum
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Chain\JsonSchema;
6+
7+
/**
8+
* @internal
9+
*/
10+
final readonly class DescriptionParser
11+
{
12+
public function fromProperty(\ReflectionProperty $property): string
13+
{
14+
$comment = $property->getDocComment();
15+
16+
if (is_string($comment) && preg_match('/@var\s+[a-zA-Z\\\\]+\s+((.*)(?=\*)|.*)/', $comment, $matches)) {
17+
return trim($matches[1]);
18+
}
19+
20+
$class = $property->getDeclaringClass();
21+
if ($class->hasMethod('__construct')) {
22+
return $this->fromParameter(
23+
new \ReflectionParameter([$class->getName(), '__construct'], $property->getName())
24+
);
25+
}
26+
27+
return '';
28+
}
29+
30+
public function fromParameter(\ReflectionParameter $parameter): string
31+
{
32+
$comment = $parameter->getDeclaringFunction()->getDocComment();
33+
if (!$comment) {
34+
return '';
35+
}
36+
37+
if (preg_match('/@param\s+\S+\s+\$'.preg_quote($parameter->getName(), '/').'\s+((.*)(?=\*)|.*)/', $comment, $matches)) {
38+
return trim($matches[1]);
39+
}
40+
41+
return '';
42+
}
43+
}

src/Chain/JsonSchema/Factory.php

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Chain\JsonSchema;
6+
7+
use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With;
8+
9+
/**
10+
* @internal
11+
*
12+
* @phpstan-type JsonSchema array{
13+
* type: 'object',
14+
* properties: array<string, array{
15+
* type: string,
16+
* description: string,
17+
* enum?: list<string>,
18+
* const?: string|int|list<string>,
19+
* pattern?: string,
20+
* minLength?: int,
21+
* maxLength?: int,
22+
* minimum?: int,
23+
* maximum?: int,
24+
* multipleOf?: int,
25+
* exclusiveMinimum?: int,
26+
* exclusiveMaximum?: int,
27+
* minItems?: int,
28+
* maxItems?: int,
29+
* uniqueItems?: bool,
30+
* minContains?: int,
31+
* maxContains?: int,
32+
* required?: bool,
33+
* minProperties?: int,
34+
* maxProperties?: int,
35+
* dependentRequired?: bool,
36+
* }>,
37+
* required: list<string>,
38+
* additionalProperties: false,
39+
* }
40+
*/
41+
final readonly class Factory
42+
{
43+
public function __construct(
44+
private DescriptionParser $descriptionParser = new DescriptionParser(),
45+
private TypeSchemaExtractor $typeSchemaExtractor = new TypeSchemaExtractor(),
46+
) {
47+
}
48+
49+
/**
50+
* @return JsonSchema|null
51+
*/
52+
public function buildParameters(string $className, string $methodName): ?array
53+
{
54+
$reflection = new \ReflectionMethod($className, $methodName);
55+
56+
return $this->convertTypes($reflection->getParameters());
57+
}
58+
59+
/**
60+
* @return JsonSchema|null
61+
*/
62+
public function buildProperties(string $className): ?array
63+
{
64+
$reflection = new \ReflectionClass($className);
65+
66+
return $this->convertTypes($reflection->getProperties());
67+
}
68+
69+
/**
70+
* @param list<\ReflectionProperty|\ReflectionParameter> $elements
71+
*
72+
* @return JsonSchema|null
73+
*/
74+
private function convertTypes(array $elements): ?array
75+
{
76+
if (0 === count($elements)) {
77+
return null;
78+
}
79+
80+
$result = [
81+
'type' => 'object',
82+
'properties' => [],
83+
'required' => [],
84+
'additionalProperties' => false,
85+
];
86+
87+
foreach ($elements as $element) {
88+
$name = $element->getName();
89+
$reflectionType = $element->getType();
90+
91+
$schema = $element instanceof \ReflectionParameter
92+
? $this->typeSchemaExtractor->fromParameter($element)
93+
: $this->typeSchemaExtractor->fromProperty($element);
94+
95+
if ($element instanceof \ReflectionProperty && $reflectionType->allowsNull()) {
96+
$schema['type'] = [$schema['type'], 'null'];
97+
}
98+
99+
if ($element instanceof \ReflectionProperty && !$reflectionType->allowsNull()) {
100+
$result['required'][] = $name;
101+
}
102+
103+
if ($element instanceof \ReflectionParameter && !$element->isOptional()) {
104+
$result['required'][] = $name;
105+
}
106+
107+
$description = $element instanceof \ReflectionParameter
108+
? $this->descriptionParser->fromParameter($element)
109+
: $this->descriptionParser->fromProperty($element);
110+
if ('' !== $description) {
111+
$schema['description'] = $description;
112+
}
113+
114+
// Check for ToolParameter attributes
115+
$attributes = $element->getAttributes(With::class);
116+
if (count($attributes) > 0) {
117+
$attributeState = array_filter((array) $attributes[0]->newInstance(), fn ($value) => null !== $value);
118+
$schema = array_merge($schema, $attributeState);
119+
}
120+
121+
$result['properties'][$name] = $schema;
122+
}
123+
124+
return $result;
125+
}
126+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Chain\JsonSchema;
6+
7+
/**
8+
* @internal
9+
*/
10+
final readonly class TypeSchemaExtractor
11+
{
12+
/**
13+
* @return array{type: string, items?: array{type: string}}
14+
*/
15+
public function fromProperty(\ReflectionProperty $property): array
16+
{
17+
$type = $property->getType();
18+
$typeName = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
19+
20+
if ('array' === $typeName) {
21+
return ['type' => 'array', 'items' => ['type' => $this->getArrayFromProperty($property)]];
22+
}
23+
24+
return $this->convertTypes($typeName);
25+
}
26+
27+
/**
28+
* @return array{type: string, items?: array{type: string}}
29+
*/
30+
public function fromParameter(\ReflectionParameter $parameter): array
31+
{
32+
$type = $parameter->getType();
33+
$typeName = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
34+
35+
if ('array' === $typeName) {
36+
return ['type' => 'array', 'items' => ['type' => $this->getArrayFromParameter($parameter)]];
37+
}
38+
39+
return $this->convertTypes($typeName);
40+
}
41+
42+
/**
43+
* @return array{type: string, format?: string}
44+
*/
45+
private function convertTypes(string $type): array
46+
{
47+
if (\DateTimeInterface::class === $type || is_subclass_of($type, \DateTimeInterface::class)) {
48+
return ['type' => 'string', 'format' => 'date-time'];
49+
}
50+
51+
return ['type' => $this->convertScalarType($type)];
52+
}
53+
54+
private function convertScalarType(string $type): string
55+
{
56+
return match ($type) {
57+
'int' => 'integer',
58+
'float' => 'number',
59+
'bool' => 'boolean',
60+
'string' => 'string',
61+
default => throw new \InvalidArgumentException(sprintf('Unknown scalar type "%s"', $type)),
62+
};
63+
}
64+
65+
private function getArrayFromProperty(\ReflectionProperty $property): string
66+
{
67+
$comment = $property->getDocComment();
68+
$class = $property->getDeclaringClass();
69+
70+
if (false === $comment && $class->hasMethod('__construct')) {
71+
return $this->getArrayFromParameter(
72+
new \ReflectionParameter([$class->getName(), '__construct'], $property->getName())
73+
);
74+
}
75+
76+
if (preg_match('/@var\s+list<([a-zA-Z]+)>\s+/', $comment, $matches)) {
77+
return $this->convertScalarType(trim($matches[1]));
78+
}
79+
80+
if (preg_match('/@var\s+([a-zA-Z]+)\[\]\s+/', $comment, $matches)) {
81+
return $this->convertScalarType(trim($matches[1]));
82+
}
83+
84+
return '';
85+
}
86+
87+
private function getArrayFromParameter(\ReflectionParameter $parameter): string
88+
{
89+
$comment = $parameter->getDeclaringFunction()->getDocComment();
90+
91+
if (preg_match('/@param\s+list<([a-zA-Z]+)>\s+\$'.preg_quote($parameter->getName(), '/').'\s+/', $comment, $matches)) {
92+
return $this->convertScalarType(trim($matches[1]));
93+
}
94+
95+
if (preg_match('/@param\s+([a-zA-Z]+)\[\]\s+\$'.preg_quote($parameter->getName(), '/').'\s+/', $comment, $matches)) {
96+
return $this->convertScalarType(trim($matches[1]));
97+
}
98+
99+
return '';
100+
}
101+
}

src/Chain/StructuredOutput/ResponseFormatFactory.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@
44

55
namespace PhpLlm\LlmChain\Chain\StructuredOutput;
66

7+
use PhpLlm\LlmChain\Chain\JsonSchema\Factory;
8+
79
use function Symfony\Component\String\u;
810

11+
/**
12+
* @internal
13+
*/
914
final readonly class ResponseFormatFactory implements ResponseFormatFactoryInterface
1015
{
1116
public function __construct(
12-
private SchemaFactory $schemaFactory = new SchemaFactory(),
17+
private Factory $schemaFactory = new Factory(),
1318
) {
1419
}
1520

@@ -19,7 +24,7 @@ public function create(string $responseClass): array
1924
'type' => 'json_schema',
2025
'json_schema' => [
2126
'name' => u($responseClass)->afterLast('\\')->toString(),
22-
'schema' => $this->schemaFactory->buildSchema($responseClass),
27+
'schema' => $this->schemaFactory->buildProperties($responseClass),
2328
'strict' => true,
2429
],
2530
];

0 commit comments

Comments
 (0)