Skip to content

Commit ffeab72

Browse files
committed
refactor: centralize json schema generation
1 parent b71864f commit ffeab72

24 files changed

+632
-526
lines changed

README.md

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

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

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

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

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

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@
1717
"php": ">=8.2",
1818
"oskarstark/enum-helper": "^1.5",
1919
"phpdocumentor/reflection-docblock": "^5.4",
20+
"phpstan/phpdoc-parser": "^2.1",
2021
"psr/cache": "^3.0",
2122
"psr/log": "^3.0",
2223
"symfony/clock": "^6.4 || ^7.1",
2324
"symfony/http-client": "^6.4 || ^7.1",
2425
"symfony/property-access": "^6.4 || ^7.1",
2526
"symfony/property-info": "^6.4 || ^7.1",
2627
"symfony/serializer": "^6.4 || ^7.1",
27-
"symfony/type-info": "^6.4 || ^7.1",
28+
"symfony/type-info": "^7.1",
2829
"symfony/uid": "^6.4 || ^7.1",
2930
"webmozart/assert": "^1.11"
3031
},

examples/test-type.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Symfony\Component\TypeInfo\TypeResolver\TypeResolver;
6+
7+
require __DIR__ . '/../vendor/autoload.php';
8+
9+
10+
class Test {
11+
public int $id;
12+
public string $test;
13+
14+
public function foo(Test $bar) {
15+
return $bar;
16+
}
17+
}
18+
19+
20+
$typeResolver = TypeResolver::create();
21+
22+
$type = $typeResolver->resolve(new \ReflectionParameter([Test::class, 'foo'], 'bar'));
23+
24+
dump($type);

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 list<int|string>|null $enum
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Chain\JsonSchema;
6+
7+
final readonly class DescriptionParser
8+
{
9+
public function getDescription(\Reflector $reflector): string
10+
{
11+
if ($reflector instanceof \ReflectionProperty) {
12+
return $this->fromProperty($reflector);
13+
}
14+
15+
if ($reflector instanceof \ReflectionParameter) {
16+
return $this->fromParameter($reflector);
17+
}
18+
19+
throw new \InvalidArgumentException(sprintf('Unsupported reflector type %s', get_class($reflector)));
20+
}
21+
22+
private function fromProperty(\ReflectionProperty $property): string
23+
{
24+
$comment = $property->getDocComment();
25+
26+
if (is_string($comment) && preg_match('/@var\s+[a-zA-Z\\\\]+\s+((.*)(?=\*)|.*)/', $comment, $matches)) {
27+
return trim($matches[1]);
28+
}
29+
30+
$class = $property->getDeclaringClass();
31+
if ($class->hasMethod('__construct')) {
32+
return $this->fromParameter(
33+
new \ReflectionParameter([$class->getName(), '__construct'], $property->getName())
34+
);
35+
}
36+
37+
return '';
38+
}
39+
40+
private function fromParameter(\ReflectionParameter $parameter): string
41+
{
42+
$comment = $parameter->getDeclaringFunction()->getDocComment();
43+
if (!$comment) {
44+
return '';
45+
}
46+
47+
if (preg_match('/@param\s+\S+\s+\$'.preg_quote($parameter->getName(), '/').'\s+((.*)(?=\*)|.*)/', $comment, $matches)) {
48+
return trim($matches[1]);
49+
}
50+
51+
return '';
52+
}
53+
}

src/Chain/JsonSchema/Factory.php

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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+
use PHPStan\BetterReflection\Reflection\ReflectionParameter;
9+
use Symfony\Component\TypeInfo\Type;
10+
use Symfony\Component\TypeInfo\Type\BuiltinType;
11+
use Symfony\Component\TypeInfo\Type\CollectionType;
12+
use Symfony\Component\TypeInfo\TypeIdentifier;
13+
use Symfony\Component\TypeInfo\TypeResolver\TypeResolver;
14+
15+
/**
16+
* @phpstan-type JsonSchema array{
17+
* type: 'object',
18+
* properties: array<string, array{
19+
* type: string,
20+
* description: string,
21+
* enum?: list<string>,
22+
* const?: string|int|list<string>,
23+
* pattern?: string,
24+
* minLength?: int,
25+
* maxLength?: int,
26+
* minimum?: int,
27+
* maximum?: int,
28+
* multipleOf?: int,
29+
* exclusiveMinimum?: int,
30+
* exclusiveMaximum?: int,
31+
* minItems?: int,
32+
* maxItems?: int,
33+
* uniqueItems?: bool,
34+
* minContains?: int,
35+
* maxContains?: int,
36+
* required?: bool,
37+
* minProperties?: int,
38+
* maxProperties?: int,
39+
* dependentRequired?: bool,
40+
* }>,
41+
* required: list<string>,
42+
* additionalProperties: false,
43+
* }
44+
*/
45+
final readonly class Factory
46+
{
47+
private TypeResolver $typeResolver;
48+
49+
public function __construct(
50+
private DescriptionParser $descriptionParser = new DescriptionParser(),
51+
?TypeResolver $typeResolver = null,
52+
) {
53+
$this->typeResolver = $typeResolver ?? TypeResolver::create();
54+
}
55+
56+
/**
57+
* @return JsonSchema|null
58+
*/
59+
public function buildParameters(string $className, string $methodName): ?array
60+
{
61+
$reflection = new \ReflectionMethod($className, $methodName);
62+
63+
return $this->convertTypes($reflection->getParameters());
64+
}
65+
66+
/**
67+
* @return JsonSchema|null
68+
*/
69+
public function buildProperties(string $className): ?array
70+
{
71+
$reflection = new \ReflectionClass($className);
72+
73+
return $this->convertTypes($reflection->getProperties());
74+
}
75+
76+
/**
77+
* @param list<\ReflectionProperty|\ReflectionParameter> $elements
78+
*
79+
* @return JsonSchema|null
80+
*/
81+
private function convertTypes(array $elements): ?array
82+
{
83+
if (0 === count($elements)) {
84+
return null;
85+
}
86+
87+
$result = [
88+
'type' => 'object',
89+
'properties' => [],
90+
'required' => [],
91+
'additionalProperties' => false,
92+
];
93+
94+
foreach ($elements as $element) {
95+
$name = $element->getName();
96+
$type = $this->typeResolver->resolve($element);
97+
$schema = $this->getTypeSchema($type);
98+
99+
if ($type->isNullable()) {
100+
$schema['type'] = [$schema['type'], 'null'];
101+
} else {
102+
$result['required'][] = $name;
103+
}
104+
105+
$description = $this->descriptionParser->getDescription($element);
106+
if ('' !== $description) {
107+
$schema['description'] = $description;
108+
}
109+
110+
// Check for ToolParameter attributes
111+
$attributes = $element->getAttributes(With::class);
112+
if (count($attributes) > 0) {
113+
$attributeState = array_filter((array) $attributes[0]->newInstance(), fn ($value) => null !== $value);
114+
$schema = array_merge($schema, $attributeState);
115+
}
116+
117+
$result['properties'][$name] = $schema;
118+
}
119+
120+
return $result;
121+
}
122+
123+
124+
/**
125+
* @return array<string, mixed>
126+
*/
127+
private function getTypeSchema(Type $type): array
128+
{
129+
switch (true) {
130+
case $type->isIdentifiedBy(TypeIdentifier::INT):
131+
return ['type' => 'integer'];
132+
133+
case $type->isIdentifiedBy(TypeIdentifier::FLOAT):
134+
return ['type' => 'number'];
135+
136+
case $type->isIdentifiedBy(TypeIdentifier::BOOL):
137+
return ['type' => 'boolean'];
138+
139+
case $type->isIdentifiedBy(TypeIdentifier::ARRAY):
140+
assert($type instanceof CollectionType);
141+
$collectionValueType = $type->getCollectionValueType();
142+
143+
if ($collectionValueType->isIdentifiedBy(TypeIdentifier::OBJECT)) {
144+
return [
145+
'type' => 'array',
146+
'items' => $this->buildProperties($collectionValueType->getClassName()),
147+
];
148+
} elseif (!empty($collectionValueTypes)) {
149+
return [
150+
'type' => 'array',
151+
'items' => $this->getTypeSchema($collectionValueType),
152+
];
153+
}
154+
155+
// Fallback for arrays
156+
return ['type' => 'array', 'items' => ['type' => 'string']];
157+
158+
case $type->isIdentifiedBy(TypeIdentifier::OBJECT):
159+
if ($type instanceof BuiltinType) {
160+
throw new \InvalidArgumentException('Cannot build schema from plain object type.');
161+
}
162+
if (\DateTimeInterface::class === $type->getClassName()) {
163+
return ['type' => 'string', 'format' => 'date-time'];
164+
} else {
165+
// Recursively build the schema for an object type
166+
return $this->buildProperties($type->getClassName());
167+
}
168+
169+
// no break
170+
case $type->isIdentifiedBy(TypeIdentifier::STRING):
171+
default:
172+
// Fallback to string for any unhandled types
173+
return ['type' => 'string'];
174+
}
175+
}
176+
}

src/Chain/StructuredOutput/ResponseFormatFactory.php

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

55
namespace PhpLlm\LlmChain\Chain\StructuredOutput;
66

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

911
final readonly class ResponseFormatFactory implements ResponseFormatFactoryInterface
1012
{
1113
public function __construct(
12-
private SchemaFactory $schemaFactory = new SchemaFactory(),
14+
private Factory $schemaFactory = new Factory(),
1315
) {
1416
}
1517

@@ -19,7 +21,7 @@ public function create(string $responseClass): array
1921
'type' => 'json_schema',
2022
'json_schema' => [
2123
'name' => u($responseClass)->afterLast('\\')->toString(),
22-
'schema' => $this->schemaFactory->buildSchema($responseClass),
24+
'schema' => $this->schemaFactory->buildProperties($responseClass),
2325
'strict' => true,
2426
],
2527
];

0 commit comments

Comments
 (0)