Skip to content

Commit 8c0e534

Browse files
committed
refactor: centralize json schema generation
1 parent a5b0315 commit 8c0e534

23 files changed

+554
-481
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,8 @@ partially support by LLMs like GPT.
209209

210210
To leverage this, configure the `#[With]` attribute on the method arguments of your tool:
211211
```php
212+
use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With;
212213
use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool;
213-
use PhpLlm\LlmChain\Chain\ToolBox\Attribute\ToolParameter;
214214

215215
#[AsTool('my_tool', 'Example tool with parameters requirements.')]
216216
final class MyTool
@@ -230,7 +230,7 @@ final class MyTool
230230
}
231231
```
232232

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

235235
> [!NOTE]
236236
> 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.2.3",
2829
"symfony/uid": "^6.4 || ^7.1",
2930
"webmozart/assert": "^1.11"
3031
},

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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(\ReflectionProperty|\ReflectionParameter $reflector): string
10+
{
11+
if ($reflector instanceof \ReflectionProperty) {
12+
return $this->fromProperty($reflector);
13+
}
14+
15+
return $this->fromParameter($reflector);
16+
}
17+
18+
private function fromProperty(\ReflectionProperty $property): string
19+
{
20+
$comment = $property->getDocComment();
21+
22+
if (is_string($comment) && preg_match('/@var\s+[a-zA-Z\\\\]+\s+((.*)(?=\*)|.*)/', $comment, $matches)) {
23+
return trim($matches[1]);
24+
}
25+
26+
$class = $property->getDeclaringClass();
27+
if ($class->hasMethod('__construct')) {
28+
return $this->fromParameter(
29+
new \ReflectionParameter([$class->getName(), '__construct'], $property->getName())
30+
);
31+
}
32+
33+
return '';
34+
}
35+
36+
private function fromParameter(\ReflectionParameter $parameter): string
37+
{
38+
$comment = $parameter->getDeclaringFunction()->getDocComment();
39+
if (!$comment) {
40+
return '';
41+
}
42+
43+
if (preg_match('/@param\s+\S+\s+\$'.preg_quote($parameter->getName(), '/').'\s+((.*)(?=\*)|.*)/', $comment, $matches)) {
44+
return trim($matches[1]);
45+
}
46+
47+
return '';
48+
}
49+
}

src/Chain/JsonSchema/Factory.php

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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 Symfony\Component\TypeInfo\Type;
9+
use Symfony\Component\TypeInfo\Type\BuiltinType;
10+
use Symfony\Component\TypeInfo\Type\CollectionType;
11+
use Symfony\Component\TypeInfo\Type\ObjectType;
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+
* @return array<string, mixed>
125+
*/
126+
private function getTypeSchema(Type $type): array
127+
{
128+
switch (true) {
129+
case $type->isIdentifiedBy(TypeIdentifier::INT):
130+
return ['type' => 'integer'];
131+
132+
case $type->isIdentifiedBy(TypeIdentifier::FLOAT):
133+
return ['type' => 'number'];
134+
135+
case $type->isIdentifiedBy(TypeIdentifier::BOOL):
136+
return ['type' => 'boolean'];
137+
138+
case $type->isIdentifiedBy(TypeIdentifier::ARRAY):
139+
assert($type instanceof CollectionType);
140+
$collectionValueType = $type->getCollectionValueType();
141+
142+
if ($collectionValueType->isIdentifiedBy(TypeIdentifier::OBJECT)) {
143+
assert($collectionValueType instanceof ObjectType);
144+
145+
return [
146+
'type' => 'array',
147+
'items' => $this->buildProperties($collectionValueType->getClassName()),
148+
];
149+
}
150+
151+
return [
152+
'type' => 'array',
153+
'items' => $this->getTypeSchema($collectionValueType),
154+
];
155+
156+
case $type->isIdentifiedBy(TypeIdentifier::OBJECT):
157+
if ($type instanceof BuiltinType) {
158+
throw new \InvalidArgumentException('Cannot build schema from plain object type.');
159+
}
160+
assert($type instanceof ObjectType);
161+
if (in_array($type->getClassName(), ['DateTime', 'DateTimeImmutable', 'DateTimeInterface'], true)) {
162+
return ['type' => 'string', 'format' => 'date-time'];
163+
} else {
164+
// Recursively build the schema for an object type
165+
return $this->buildProperties($type->getClassName());
166+
}
167+
168+
// no break
169+
case $type->isIdentifiedBy(TypeIdentifier::STRING):
170+
default:
171+
// Fallback to string for any unhandled types
172+
return ['type' => 'string'];
173+
}
174+
}
175+
}

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)